diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:34:42 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:34:42 +0000 |
commit | da4c7e7ed675c3bf405668739c3012d140856109 (patch) | |
tree | cdd868dba063fecba609a1d819de271f0d51b23e /mobile/android/android-components/components/service | |
parent | Adding upstream version 125.0.3. (diff) | |
download | firefox-da4c7e7ed675c3bf405668739c3012d140856109.tar.xz firefox-da4c7e7ed675c3bf405668739c3012d140856109.zip |
Adding upstream version 126.0.upstream/126.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/service')
356 files changed, 27710 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/service/contile/README.md b/mobile/android/android-components/components/service/contile/README.md new file mode 100644 index 0000000000..c53b90af05 --- /dev/null +++ b/mobile/android/android-components/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/mobile/android/android-components/components/service/contile/build.gradle b/mobile/android/android-components/components/service/contile/build.gradle new file mode 100644 index 0000000000..6d0fa94c29 --- /dev/null +++ b/mobile/android/android-components/components/service/contile/build.gradle @@ -0,0 +1,45 @@ +/* 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.contile' +} + +dependencies { + implementation ComponentsDependencies.kotlin_coroutines + implementation ComponentsDependencies.androidx_work_runtime + + implementation project(':concept-fetch') + implementation project(':support-ktx') + implementation project(':support-base') + implementation project(':feature-top-sites') + + testImplementation ComponentsDependencies.androidx_test_core + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.androidx_work_testing + testImplementation ComponentsDependencies.testing_robolectric + testImplementation ComponentsDependencies.testing_coroutines + + testImplementation project(':support-test') +} + +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/contile/proguard-rules.pro b/mobile/android/android-components/components/service/contile/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/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/mobile/android/android-components/components/service/contile/src/main/AndroidManifest.xml b/mobile/android/android-components/components/service/contile/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e16cda1d34 --- /dev/null +++ b/mobile/android/android-components/components/service/contile/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/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesProvider.kt b/mobile/android/android-components/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesProvider.kt new file mode 100644 index 0000000000..b1d10bb3c9 --- /dev/null +++ b/mobile/android/android-components/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesProvider.kt @@ -0,0 +1,307 @@ +/* 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 android.text.format.DateUtils +import android.util.AtomicFile +import androidx.annotation.VisibleForTesting +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Headers +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +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 mozilla.components.support.ktx.android.org.json.tryGetLong +import mozilla.components.support.ktx.util.readAndDeserialize +import mozilla.components.support.ktx.util.writeString +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.File +import java.io.IOException +import java.util.Date + +internal const val CONTILE_ENDPOINT_URL = "https://contile.services.mozilla.com/v1/tiles" +internal const val CACHE_FILE_NAME = "mozilla_components_service_contile.json" +internal const val CACHE_VALID_FOR_KEY = "valid_for" +internal const val CACHE_TOP_SITES_KEY = "tiles" + +/** + * Provides access to the Contile services API. + * + * @param context A reference to the application context. + * @property client [Client] used for interacting with the Contile HTTP API. + * @property endPointURL The url of the endpoint to fetch from. Defaults to [CONTILE_ENDPOINT_URL]. + * @property maxCacheAgeInSeconds Maximum time (in seconds) the cache should remain valid + * before a refresh is attempted. Defaults to -1, meaning the max age defined by the server + * will be used. + */ +class ContileTopSitesProvider( + context: Context, + private val client: Client, + private val endPointURL: String = CONTILE_ENDPOINT_URL, + private val maxCacheAgeInSeconds: Long = -1, +) : TopSitesProvider { + + private val applicationContext = context.applicationContext + private val logger = Logger("ContileTopSitesProvider") + private val diskCacheLock = Any() + + // Current state of the cache. + @VisibleForTesting + @Volatile + internal var cacheState: CacheState = CacheState() + + /** + * Fetches from the top sites [endPointURL] to provide a list of provided top sites. + * Returns a cached response if [allowCache] is true and the cache is not expired + * (@see [maxCacheAgeInSeconds]). + * + * @param allowCache Whether or not the result may be provided from a previously cached + * response. + * @throws IOException if the request failed to fetch any top sites. + */ + @Throws(IOException::class) + override suspend fun getTopSites(allowCache: Boolean): List<TopSite.Provided> { + val cachedTopSites = if (allowCache) { + getCachedTopSitesIfValid(shouldUseServerMaxAge = false) + } else { + null + } + if (!cachedTopSites.isNullOrEmpty()) { + return cachedTopSites + } + + return try { + fetchTopSites() + } catch (e: IOException) { + logger.error("Failed to fetch contile top sites", e) + throw e + } + } + + /** + * Refreshes the cache with the latest top sites response from [endPointURL] + * if the cache is expired. + */ + suspend fun refreshTopSitesIfCacheExpired() { + if (!isCacheExpired(shouldUseServerMaxAge = false)) return + + getTopSites(allowCache = false) + } + + private fun fetchTopSites(): List<TopSite.Provided> { + client.fetch( + Request(url = endPointURL, conservative = true), + ).use { response -> + if (response.isSuccess) { + val responseBody = response.body.string(Charsets.UTF_8) + + if (response.status == Response.NO_CONTENT) { + // If the response is 204, we should invalidate the cached top sites + cacheState = cacheState.invalidate() + getCacheFile().delete() + return listOf() + } + + return try { + val jsonBody = JSONObject(responseBody) + writeToDiskCache( + response.headers.computeValidFor() * DateUtils.SECOND_IN_MILLIS, + jsonBody.getJSONArray(CACHE_TOP_SITES_KEY), + ) + + jsonBody.getTopSites() + } catch (e: JSONException) { + throw IOException(e) + } + } else { + // If fetch failed, we should check if the set of top sites is still valid + // and use it as fallback. + val topSites = getCachedTopSitesIfValid(shouldUseServerMaxAge = true) + if (!topSites.isNullOrEmpty()) { + return topSites + } + val errorMessage = + "Failed to fetch contile top sites. Status code: ${response.status}" + logger.error(errorMessage) + throw IOException(errorMessage) + } + } + } + + @VisibleForTesting + internal fun readFromDiskCache(): CachedData? { + synchronized(diskCacheLock) { + return getCacheFile().readAndDeserialize { + JSONObject(it).let { cachedObject -> + CachedData(cachedObject.validFor, cachedObject.getTopSites()) + } + } + } + } + + /** + * Write the validity time and top sites to a file for caching purposes. + * + * @param validFor Time in milliseconds describing the click validity for the set of top sites. + * @param topSites [JSONArray] containing the top sites to be cached. + */ + @VisibleForTesting + internal fun writeToDiskCache(validFor: Long, topSites: JSONArray) { + val cachedData = JSONObject().apply { + put(CACHE_VALID_FOR_KEY, validFor) + put(CACHE_TOP_SITES_KEY, topSites) + } + synchronized(diskCacheLock) { + getCacheFile().let { + it.writeString { cachedData.toString() } + + // Update the cache state to reflect the current status + cacheState = cacheState.computeMaxAges( + System.currentTimeMillis(), + maxCacheAgeInSeconds * DateUtils.SECOND_IN_MILLIS, + validFor, + ) + } + } + } + + /** + * Returns the cached top sites if the cached data is not expired, based on the client or server + * specified max age, else null is returned. In the case of a server outage, the cached server + * max age should be used as fallback. + * + * @param shouldUseServerMaxAge True if server cache max age should be used. + */ + private fun getCachedTopSitesIfValid(shouldUseServerMaxAge: Boolean) = + if (!isCacheExpired(shouldUseServerMaxAge)) { + readFromDiskCache()?.topSites + } else { + null + } + + @VisibleForTesting + internal fun isCacheExpired(shouldUseServerMaxAge: Boolean): Boolean { + cacheState.getCacheMaxAge(shouldUseServerMaxAge)?.let { return Date().time > it } + + val file = getBaseCacheFile() + + cacheState = + if (file.exists()) { + cacheState.computeMaxAges( + file.lastModified(), + maxCacheAgeInSeconds * DateUtils.SECOND_IN_MILLIS, + (readFromDiskCache()?.validFor ?: 0L), + ) + } else { + cacheState.invalidate() + } + + // If cache is invalid, we should also consider it as expired + return Date().time > (cacheState.getCacheMaxAge(shouldUseServerMaxAge) ?: -1L) + } + + private fun getCacheFile(): AtomicFile = AtomicFile(getBaseCacheFile()) + + @VisibleForTesting + internal fun getBaseCacheFile(): File = File(applicationContext.filesDir, CACHE_FILE_NAME) + + /** + * Data stored in the cache file + * + * @param validFor Time in milliseconds describing the click validity for the set of top sites. + * @param topSites List of provided top sites. + */ + internal data class CachedData( + val validFor: Long, + val topSites: List<TopSite.Provided>, + ) + + /** + * Current state of the cache. + * + * @param isCacheValid Whether or not the current set of cached top sites is still valid. + * @param localCacheMaxAge Maximum unix timestamp until the current set of cached top sites + * is still valid, specified by the client. + * @param serverCacheMaxAge Maximum unix timestamp until the current set of cached top sites + * is still valid, specified by the server. + */ + internal data class CacheState( + val isCacheValid: Boolean = true, + val localCacheMaxAge: Long? = null, + val serverCacheMaxAge: Long? = null, + ) { + fun getCacheMaxAge(shouldUseServerMaxAge: Boolean = false) = if (isCacheValid) { + if (shouldUseServerMaxAge) serverCacheMaxAge else localCacheMaxAge + } else { + null + } + + fun invalidate(): CacheState = + this.copy(isCacheValid = false, localCacheMaxAge = null, serverCacheMaxAge = null) + + /** + * Update local and server max age values. + * + * @param lastModified Unix timestamp when the cache was last modified. + * @param localMaxAge Validity of local cache in milliseconds. + * @param serverMaxAge Server specified validity in milliseconds. To be used as fallback + * when local max age is exceeded and a server outage is detected. + */ + fun computeMaxAges(lastModified: Long, localMaxAge: Long, serverMaxAge: Long): CacheState = + this.copy( + isCacheValid = true, + localCacheMaxAge = lastModified + localMaxAge, + serverCacheMaxAge = lastModified + serverMaxAge, + ) + } +} + +/** + * To extract the `valid-for` value for the set of provided top sites, we need to sum up the `max-age` + * and `stale-if-error` options from the header. These values can be found in the `cache-control` header, + * formatted as `max-age=$value` and `stale-if-error=$value`. + */ +internal fun Headers.computeValidFor(): Long = + getAll("cache-control").sumOf { + val valueList = it.split("=") + .map { item -> item.trim() } + + if (valueList.size == 2 && valueList[0] in listOf("max-age", "stale-if-error")) { + valueList[1].toLong() + } else { + 0L + } + } + +internal fun JSONObject.getTopSites(): List<TopSite.Provided> = + getJSONArray(CACHE_TOP_SITES_KEY) + .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"), + createdAt = null, + ) + } catch (e: JSONException) { + null + } +} + +internal val JSONObject.validFor: Long + get() = this.tryGetLong(CACHE_VALID_FOR_KEY) ?: 0L diff --git a/mobile/android/android-components/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUpdater.kt b/mobile/android/android-components/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUpdater.kt new file mode 100644 index 0000000000..b8d21ec854 --- /dev/null +++ b/mobile/android/android-components/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUpdater.kt @@ -0,0 +1,81 @@ +/* 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 mozilla.components.support.base.worker.Frequency +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<ContileTopSitesUpdaterWorker>( + 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" + } +} diff --git a/mobile/android/android-components/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUpdaterWorker.kt b/mobile/android/android-components/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUpdaterWorker.kt new file mode 100644 index 0000000000..35ce3d6094 --- /dev/null +++ b/mobile/android/android-components/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/mobile/android/android-components/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUseCases.kt b/mobile/android/android-components/components/service/contile/src/main/java/mozilla/components/service/contile/ContileTopSitesUseCases.kt new file mode 100644 index 0000000000..c3fcaa5077 --- /dev/null +++ b/mobile/android/android-components/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/mobile/android/android-components/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesProviderTest.kt b/mobile/android/android-components/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesProviderTest.kt new file mode 100644 index 0000000000..be8747881d --- /dev/null +++ b/mobile/android/android-components/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesProviderTest.kt @@ -0,0 +1,335 @@ +/* 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.text.format.DateUtils +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Header +import mozilla.components.concept.fetch.Headers +import mozilla.components.concept.fetch.MutableHeaders +import mozilla.components.concept.fetch.Response +import mozilla.components.feature.top.sites.TopSite +import mozilla.components.support.test.any +import mozilla.components.support.test.eq +import mozilla.components.support.test.file.loadResourceAsString +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.whenever +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.anyLong +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import java.io.File +import java.io.IOException +import java.util.Date + +@ExperimentalCoroutinesApi // for runTest +@RunWith(AndroidJUnit4::class) +class ContileTopSitesProviderTest { + + @Test + fun `GIVEN a successful status response WHEN top sites are fetched THEN response should contain top sites`() = + runTest { + val client = prepareClient() + val provider = ContileTopSitesProvider(testContext, 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) + + 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) + } + + @Test(expected = IOException::class) + fun `GIVEN a 500 status response WHEN top sites are fetched AND cached top sites are not valid THEN throw an exception`() = + runTest { + val client = prepareClient(status = 500) + val provider = ContileTopSitesProvider(testContext, client) + provider.getTopSites() + } + + @Test + fun `GIVEN a 500 status response WHEN top sites are fetched AND cached top sites are valid THEN return the cached top sites`() = + runTest { + val client = prepareClient(status = 500) + val topSites = mock<List<TopSite.Provided>>() + val provider = + spy(ContileTopSitesProvider(testContext, client, maxCacheAgeInSeconds = 60L)) + whenever(provider.isCacheExpired(false)).thenReturn(true) + whenever(provider.isCacheExpired(true)).thenReturn(false) + whenever(provider.readFromDiskCache()).thenReturn( + ContileTopSitesProvider.CachedData( + 60L, + topSites, + ), + ) + + assertEquals(topSites, provider.getTopSites()) + } + + @Test + fun `GIVEN a cache configuration is allowed and not expired WHEN top sites are fetched THEN read from the disk cache`() = + runTest { + val client = prepareClient() + val provider = spy(ContileTopSitesProvider(testContext, client)) + + provider.getTopSites(allowCache = false) + verify(provider, never()).readFromDiskCache() + + whenever(provider.isCacheExpired(false)).thenReturn(true) + provider.getTopSites(allowCache = true) + verify(provider, never()).readFromDiskCache() + + whenever(provider.isCacheExpired(false)).thenReturn(false) + provider.getTopSites(allowCache = true) + verify(provider).readFromDiskCache() + } + + @Test + fun `GIVEN a set of top sites is cached WHEN checking the server specified cache max age THEN max age is calculated correctly`() = + runTest { + val client = prepareClient() + val provider = spy(ContileTopSitesProvider(testContext, client)) + val file = mock<File> { + whenever(exists()).thenReturn(true) + whenever(lastModified()).thenReturn(Date().time) + } + + whenever(provider.readFromDiskCache()).thenReturn( + ContileTopSitesProvider.CachedData( + 300000, + mock(), + ), + ) + whenever(provider.getBaseCacheFile()).thenReturn(file) + + assertFalse(provider.isCacheExpired(shouldUseServerMaxAge = true)) + + provider.cacheState = provider.cacheState.invalidate() + whenever(file.lastModified()).thenReturn(Date().time - 500000) + whenever(provider.getBaseCacheFile()).thenReturn(file) + + assertTrue(provider.isCacheExpired(shouldUseServerMaxAge = true)) + } + + @Test + fun `GIVEN a cache max age is specified WHEN top sites are fetched THEN the cache max age is correctly set`() = + runTest { + val jsonResponse = loadResourceAsString("/contile/contile.json") + val client = prepareClient(jsonResponse) + val specifiedProvider = spy( + ContileTopSitesProvider( + context = testContext, + client = client, + maxCacheAgeInSeconds = 60L, + ), + ) + + specifiedProvider.getTopSites() + verify(specifiedProvider).writeToDiskCache(anyLong(), any()) + assertEquals( + specifiedProvider.cacheState.localCacheMaxAge, + specifiedProvider.cacheState.getCacheMaxAge(), + ) + assertFalse(specifiedProvider.isCacheExpired(false)) + } + + @Test + fun `GIVEN cache max age is not specified WHEN top sites are fetched THEN the cache is expired`() = + runTest { + val jsonResponse = loadResourceAsString("/contile/contile.json") + val client = prepareClient(jsonResponse) + val provider = spy(ContileTopSitesProvider(testContext, client)) + + provider.getTopSites() + verify(provider).writeToDiskCache(anyLong(), any()) + assertTrue(provider.isCacheExpired(false)) + } + + @Test + fun `WHEN the base cache file getter is called THEN return existing base cache file`() { + val client = prepareClient() + val provider = spy(ContileTopSitesProvider(testContext, client)) + val file = File(testContext.filesDir, CACHE_FILE_NAME) + + file.createNewFile() + + assertTrue(file.exists()) + + val cacheFile = provider.getBaseCacheFile() + + assertTrue(cacheFile.exists()) + assertEquals(file.name, cacheFile.name) + + assertTrue(file.delete()) + assertFalse(cacheFile.exists()) + } + + @Test + fun `WHEN the cache expiration is checked THEN return whether the cache is expired`() { + val provider = + spy(ContileTopSitesProvider(testContext, client = mock())) + + provider.cacheState = + ContileTopSitesProvider.CacheState(isCacheValid = false) + assertTrue(provider.isCacheExpired(false)) + + provider.cacheState = ContileTopSitesProvider.CacheState( + isCacheValid = true, + localCacheMaxAge = Date().time - 60 * DateUtils.MINUTE_IN_MILLIS, + ) + assertTrue(provider.isCacheExpired(false)) + + provider.cacheState = ContileTopSitesProvider.CacheState( + isCacheValid = true, + localCacheMaxAge = Date().time + 60 * DateUtils.MINUTE_IN_MILLIS, + ) + assertFalse(provider.isCacheExpired(false)) + + provider.cacheState = ContileTopSitesProvider.CacheState( + isCacheValid = true, + serverCacheMaxAge = Date().time - 60 * DateUtils.MINUTE_IN_MILLIS, + ) + assertTrue(provider.isCacheExpired(true)) + + provider.cacheState = ContileTopSitesProvider.CacheState( + isCacheValid = true, + serverCacheMaxAge = Date().time + 60 * DateUtils.MINUTE_IN_MILLIS, + ) + assertFalse(provider.isCacheExpired(true)) + } + + @Test + fun `GIVEN cache is not expired WHEN top sites are refreshed THEN do nothing`() = runTest { + val provider = spy( + ContileTopSitesProvider( + testContext, + client = prepareClient(), + maxCacheAgeInSeconds = 600, + ), + ) + + whenever(provider.isCacheExpired(false)).thenReturn(false) + provider.refreshTopSitesIfCacheExpired() + verify(provider, never()).getTopSites(allowCache = false) + } + + @Test + fun `GIVEN cache is expired WHEN top sites are refreshed THEN fetch and write new response to cache`() = + runTest { + val jsonResponse = loadResourceAsString("/contile/contile.json") + val provider = spy( + ContileTopSitesProvider( + testContext, + client = prepareClient(jsonResponse), + ), + ) + + whenever(provider.isCacheExpired(false)).thenReturn(true) + + provider.refreshTopSitesIfCacheExpired() + + verify(provider).getTopSites(allowCache = false) + verify(provider).writeToDiskCache(eq(300000L), any()) + } + + @Test + fun `GIVEN a NO_CONTENT status response WHEN top sites are fetched THEN cache is cleared`() = runTest { + val client = prepareClient(status = Response.NO_CONTENT) + val provider = spy(ContileTopSitesProvider(testContext, client)) + val file = mock<File>() + + whenever(provider.isCacheExpired(false)).thenReturn(true) + whenever(provider.getBaseCacheFile()).thenReturn(file) + provider.refreshTopSitesIfCacheExpired() + + verify(file).delete() + assertNull(provider.cacheState.localCacheMaxAge) + assertNull(provider.cacheState.serverCacheMaxAge) + } + + private fun prepareClient( + jsonResponse: String = loadResourceAsString("/contile/contile.json"), + status: Int = 200, + headers: Headers = MutableHeaders( + listOf( + Header("cache-control", "max-age=100"), + Header("cache-control", "stale-if-error=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(mockedResponse.headers).thenReturn(headers) + whenever(mockedClient.fetch(any())).thenReturn(mockedResponse) + + return mockedClient + } + + @Test + fun `WHEN extracting top sites from a json object contains top sites THEN all top sites are correctly set`() { + val topSites = with( + TopSite.Provided( + 1, + "firefox", + "www.mozilla.com", + "www.mozilla.com", + "www.mozilla.com", + "www.mozilla.com", + null, + ), + ) { + listOf(this, this.copy(id = 2)) + } + + val jsonObject = JSONObject( + mapOf( + CACHE_TOP_SITES_KEY to JSONArray().also { array -> + topSites.map { it.toJsonObject() }.forEach { array.put(it) } + }, + ), + ) + + assertEquals(topSites, jsonObject.getTopSites()) + } + + private fun TopSite.Provided.toJsonObject() = + JSONObject() + .put("id", id) + .put("name", title) + .put("url", url) + .put("click_url", clickUrl) + .put("image_url", imageUrl) + .put("impression_url", impressionUrl) +} diff --git a/mobile/android/android-components/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUpdaterTest.kt b/mobile/android/android-components/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUpdaterTest.kt new file mode 100644 index 0000000000..9641b1a5d6 --- /dev/null +++ b/mobile/android/android-components/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUpdaterTest.kt @@ -0,0 +1,97 @@ +/* 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.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +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 + +@ExperimentalCoroutinesApi // for runTest +@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`() = runTest { + 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`() = runTest { + 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/mobile/android/android-components/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUpdaterWorkerTest.kt b/mobile/android/android-components/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUpdaterWorkerTest.kt new file mode 100644 index 0000000000..e4f2e7f63c --- /dev/null +++ b/mobile/android/android-components/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUpdaterWorkerTest.kt @@ -0,0 +1,69 @@ +/* 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.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +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 + +@ExperimentalCoroutinesApi // for runTest +@RunWith(AndroidJUnit4::class) +class ContileTopSitesUpdaterWorkerTest { + + @After + fun cleanup() { + ContileTopSitesUseCases.destroy() + } + + @Test + fun `WHEN worker does successful work THEN return a success result`() = runTest { + val provider: ContileTopSitesProvider = mock() + val worker = spy( + TestListenableWorkerBuilder<ContileTopSitesUpdaterWorker>(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`() = runTest { + val provider: ContileTopSitesProvider = mock() + val worker = spy( + TestListenableWorkerBuilder<ContileTopSitesUpdaterWorker>(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/mobile/android/android-components/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUseCasesTest.kt b/mobile/android/android-components/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUseCasesTest.kt new file mode 100644 index 0000000000..354367f2a1 --- /dev/null +++ b/mobile/android/android-components/components/service/contile/src/test/java/mozilla/components/service/contile/ContileTopSitesUseCasesTest.kt @@ -0,0 +1,48 @@ +/* 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 kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.support.test.eq +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.junit.Test +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.Mockito.verify +import java.io.IOException + +@ExperimentalCoroutinesApi // for runTest +class ContileTopSitesUseCasesTest { + + @Test + fun `WHEN refresh contile top site use case is called THEN call the provider to fetch top sites bypassing the cache`() = runTest { + 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`() = runTest { + val provider: ContileTopSitesProvider = mock() + val throwable = IOException("test") + + ContileTopSitesUseCases.initialize(provider) + + whenever(provider.getTopSites(anyBoolean())).then { + throw throwable + } + + ContileTopSitesUseCases().refreshContileTopSites.invoke() + } +} diff --git a/mobile/android/android-components/components/service/contile/src/test/resources/contile/contile.json b/mobile/android/android-components/components/service/contile/src/test/resources/contile/contile.json new file mode 100644 index 0000000000..7668717e10 --- /dev/null +++ b/mobile/android/android-components/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/mobile/android/android-components/components/service/contile/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/service/contile/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..cf1c399ea8 --- /dev/null +++ b/mobile/android/android-components/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/mobile/android/android-components/components/service/contile/src/test/resources/robolectric.properties b/mobile/android/android-components/components/service/contile/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/service/contile/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/mobile/android/android-components/components/service/digitalassetlinks/README.md b/mobile/android/android-components/components/service/digitalassetlinks/README.md new file mode 100644 index 0000000000..f130dfd7a5 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/README.md @@ -0,0 +1,40 @@ +# [Android Components](../../../README.md) > Service > Digital Asset Links + +A library for communicating with the [Digital Asset Links](https://developers.google.com/digital-asset-links) 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-digital-asset-links:{latest-version}" +``` + +### Obtaining an AssetDescriptor + +For web sites, asset descriptors can be obtained by simply passing the origin into the `AssetDescriptor.Web` constructor. + +```kotlin +AssetDescriptor.Web( + site = "https://{fully-qualified domain}{:optional port}" +) +``` + +For Android apps, a fingerprint corresponding to the Android app must be used. This can be obtained using the `AndroidAssetFinder` class. + +### Remote API + +The `DigitalAssetLinksApi` class will handle checking asset links by calling [Google's remote API](https://developers.google.com/digital-asset-links/reference/rest). An API key must be given to the class. + +### Local API + +The `StatementRelationChecker` class will handle checking asset links on device by fetching and iterating through asset link statements located on a website. Either the `StatementApi` or `DigitalAssetLinksApi` classes may be used to obtain a statement list. + +## 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/digitalassetlinks/build.gradle b/mobile/android/android-components/components/service/digitalassetlinks/build.gradle new file mode 100644 index 0000000000..14c2c992f2 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/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 { + 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.digitalassetlinks' +} + +dependencies { + implementation ComponentsDependencies.androidx_core_ktx + + implementation project(':concept-fetch') + implementation project(':support-base') + implementation project(':support-ktx') + implementation project(':support-utils') + + testImplementation ComponentsDependencies.androidx_test_core + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.testing_junit + testImplementation ComponentsDependencies.testing_robolectric + testImplementation project(':support-test') +} + +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/digitalassetlinks/proguard-rules.pro b/mobile/android/android-components/components/service/digitalassetlinks/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/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/digitalassetlinks/src/main/AndroidManifest.xml b/mobile/android/android-components/components/service/digitalassetlinks/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e16cda1d34 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/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/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinder.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinder.kt new file mode 100644 index 0000000000..bd4e497d34 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinder.kt @@ -0,0 +1,128 @@ +/* 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.digitalassetlinks + +import android.annotation.SuppressLint +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.Signature +import android.os.Build +import android.os.Build.VERSION.SDK_INT +import androidx.annotation.VisibleForTesting +import mozilla.components.support.utils.ext.getPackageInfoCompat +import java.io.ByteArrayInputStream +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.security.cert.CertificateEncodingException +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate + +/** + * Get the SHA256 certificate for an installed Android app. + */ +class AndroidAssetFinder { + + /** + * Converts the Android App with the given package name into an asset descriptor + * by computing the SHA256 certificate for each signing signature. + * + * The output is lazily computed. If desired, only the first item from the sequence could + * be used and other certificates (if any) will not be computed. + */ + fun getAndroidAppAsset( + packageName: String, + packageManager: PackageManager, + ): Sequence<AssetDescriptor.Android> { + return packageManager.getSignatures(packageName).asSequence() + .mapNotNull { signature -> getCertificateSHA256Fingerprint(signature) } + .map { fingerprint -> AssetDescriptor.Android(packageName, fingerprint) } + } + + /** + * Computes the SHA256 certificate for the given package name. The app with the given package + * name has to be installed on device. The output will be a 30 long HEX string with : between + * each value. + * @return The SHA256 certificate for the package name. + */ + @VisibleForTesting + internal fun getCertificateSHA256Fingerprint(signature: Signature): String? { + val input = ByteArrayInputStream(signature.toByteArray()) + return try { + val certificate = CertificateFactory.getInstance("X509").generateCertificate(input) as X509Certificate + byteArrayToHexString(MessageDigest.getInstance("SHA256").digest(certificate.encoded)) + } catch (e: CertificateEncodingException) { + // Certificate type X509 encoding failed + null + } catch (e: CertificateException) { + throw AssertionError("Should not happen", e) + } catch (e: NoSuchAlgorithmException) { + throw AssertionError("Should not happen", e) + } + } + + @Suppress("PackageManagerGetSignatures") + // https://stackoverflow.com/questions/39192844/android-studio-warning-when-using-packagemanager-get-signatures + private fun PackageManager.getSignatures(packageName: String): Array<Signature> { + val packageInfo = getPackageSignatureInfo(packageName) ?: return emptyArray() + + return if (SDK_INT >= Build.VERSION_CODES.P) { + val signingInfo = packageInfo.signingInfo + if (signingInfo.hasMultipleSigners()) { + signingInfo.apkContentsSigners + } else { + val history = signingInfo.signingCertificateHistory + if (history.isEmpty()) { + emptyArray() + } else { + arrayOf(history.first()) + } + } + } else { + @Suppress("Deprecation") + packageInfo.signatures + } + } + + @SuppressLint("PackageManagerGetSignatures") + private fun PackageManager.getPackageSignatureInfo(packageName: String): PackageInfo? { + return try { + if (SDK_INT >= Build.VERSION_CODES.P) { + getPackageInfoCompat(packageName, PackageManager.GET_SIGNING_CERTIFICATES) + } else { + @Suppress("Deprecation") + getPackageInfo(packageName, PackageManager.GET_SIGNATURES) + } + } catch (e: PackageManager.NameNotFoundException) { + // Will return null if there is no package found. + return null + } + } + + /** + * Converts a byte array to hex string with : inserted between each element. + * @param bytes The array to be converted. + * @return A string with two letters representing each byte and : in between. + */ + @Suppress("MagicNumber") + @VisibleForTesting + internal fun byteArrayToHexString(bytes: ByteArray): String { + val hexString = StringBuilder(bytes.size * HEX_STRING_SIZE - 1) + var v: Int + for (j in bytes.indices) { + v = bytes[j].toInt() and 0xFF + hexString.append(HEX_CHAR_LOOKUP[v.ushr(HALF_BYTE)]) + hexString.append(HEX_CHAR_LOOKUP[v and 0x0F]) + if (j < bytes.lastIndex) hexString.append(':') + } + return hexString.toString() + } + + companion object { + private const val HALF_BYTE = 4 + private const val HEX_STRING_SIZE = "0F:".length + private val HEX_CHAR_LOOKUP = "0123456789ABCDEF".toCharArray() + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AssetDescriptor.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AssetDescriptor.kt new file mode 100644 index 0000000000..a3f81799e2 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/AssetDescriptor.kt @@ -0,0 +1,41 @@ +/* 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.digitalassetlinks + +/** + * Uniquely identifies an asset. + * + * A digital asset is an identifiable and addressable online entity that typically provides some + * service or content. + */ +sealed class AssetDescriptor { + + /** + * A web site asset descriptor. + * @property site URI representing the domain of the website. + * @sample + * AssetDescriptor.Web( + * site = "https://{fully-qualified domain}{:optional port}" + * ) + */ + data class Web( + val site: String, + ) : AssetDescriptor() + + /** + * An Android app asset descriptor. + * @property packageName Package name for the Android app. + * @property sha256CertFingerprint A colon-separated hex string. + * @sample + * AssetDescriptor.Android( + * packageName = "com.costingtons.app", + * sha256CertFingerprint = "A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5" + * ) + */ + data class Android( + val packageName: String, + val sha256CertFingerprint: String, + ) : AssetDescriptor() +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Constants.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Constants.kt new file mode 100644 index 0000000000..e1860c0463 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Constants.kt @@ -0,0 +1,10 @@ +/* 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.digitalassetlinks + +import java.util.concurrent.TimeUnit + +@Suppress("MagicNumber", "TopLevelPropertyNaming") +internal val TIMEOUT = 3L to TimeUnit.SECONDS diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Relation.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Relation.kt new file mode 100644 index 0000000000..2cc4c32429 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Relation.kt @@ -0,0 +1,29 @@ +/* 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.digitalassetlinks + +/** + * Describes the nature of a statement, and consists of a kind and a detail. + * @property kindAndDetail Kind and detail, separated by a slash character. + */ +enum class Relation(val kindAndDetail: String) { + + /** + * Grants the target permission to retrieve sign-in credentials stored for the source. + * For App -> Web transitions, requests the app to use the declared origin to be used as origin + * for the client app in the web APIs context. + */ + USE_AS_ORIGIN("delegate_permission/common.use_as_origin"), + + /** + * Requests the ability to handle all URLs from a given origin. + */ + HANDLE_ALL_URLS("delegate_permission/common.handle_all_urls"), + + /** + * Grants the target permission to retrieve sign-in credentials stored for the source. + */ + GET_LOGIN_CREDS("delegate_permission/common.get_login_creds"), +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/RelationChecker.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/RelationChecker.kt new file mode 100644 index 0000000000..7134b6cd68 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/RelationChecker.kt @@ -0,0 +1,21 @@ +/* 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.digitalassetlinks + +/** + * Verifies that a source is linked to a target. + */ +interface RelationChecker { + + /** + * Performs a check to ensure a directional relationships exists between the specified + * [source] and [target] assets. The relationship must match the [relation] type given. + */ + fun checkRelationship( + source: AssetDescriptor.Web, + relation: Relation, + target: AssetDescriptor, + ): Boolean +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Statement.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Statement.kt new file mode 100644 index 0000000000..5686e74375 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/Statement.kt @@ -0,0 +1,25 @@ +/* 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.digitalassetlinks + +/** + * Represents a statement that can be found in an assetlinks.json file. + */ +sealed class StatementResult + +/** + * Entry in a Digital Asset Links statement file. + */ +data class Statement( + val relation: Relation, + val target: AssetDescriptor, +) : StatementResult() + +/** + * Include statements point to another Digital Asset Links statement file. + */ +data class IncludeStatement( + val include: String, +) : StatementResult() diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/StatementListFetcher.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/StatementListFetcher.kt new file mode 100644 index 0000000000..435332635f --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/StatementListFetcher.kt @@ -0,0 +1,16 @@ +/* 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.digitalassetlinks + +/** + * Lists all statements made by a given source. + */ +interface StatementListFetcher { + + /** + * Retrieves a list of all statements from a given [source]. + */ + fun listStatements(source: AssetDescriptor.Web): Sequence<Statement> +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/CheckAssetLinksResponse.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/CheckAssetLinksResponse.kt new file mode 100644 index 0000000000..1ab14d687e --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/CheckAssetLinksResponse.kt @@ -0,0 +1,25 @@ +/* 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.digitalassetlinks.api + +import org.json.JSONObject + +/** + * @property linked True if the assets specified in the request are linked by the relation specified in the request. + * @property maxAge From serving time, how much longer the response should be considered valid barring further updates. + * Formatted as a duration in seconds with up to nine fractional digits, terminated by 's'. Example: "3.5s". + * @property debug Human-readable message containing information about the response. + */ +data class CheckAssetLinksResponse( + val linked: Boolean, + val maxAge: String, + val debug: String, +) + +internal fun parseCheckAssetLinksJson(json: JSONObject) = CheckAssetLinksResponse( + linked = json.getBoolean("linked"), + maxAge = json.getString("maxAge"), + debug = json.optString("debugString"), +) diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApi.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApi.kt new file mode 100644 index 0000000000..c1a09e06c2 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApi.kt @@ -0,0 +1,120 @@ +/* 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.digitalassetlinks.api + +import android.net.Uri +import androidx.annotation.VisibleForTesting +import androidx.core.net.toUri +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.service.digitalassetlinks.AssetDescriptor +import mozilla.components.service.digitalassetlinks.Relation +import mozilla.components.service.digitalassetlinks.RelationChecker +import mozilla.components.service.digitalassetlinks.Statement +import mozilla.components.service.digitalassetlinks.StatementListFetcher +import mozilla.components.service.digitalassetlinks.TIMEOUT +import mozilla.components.service.digitalassetlinks.ext.parseJsonBody +import mozilla.components.service.digitalassetlinks.ext.safeFetch +import mozilla.components.support.ktx.kotlin.sanitizeURL +import org.json.JSONObject + +/** + * Digital Asset Links allows any caller to check pre declared relationships between + * two assets which can be either web domains or native applications. + * This class checks for a specific relationship declared by two assets via the online API. + */ +class DigitalAssetLinksApi( + private val httpClient: Client, + private val apiKey: String?, +) : RelationChecker, StatementListFetcher { + + override fun checkRelationship( + source: AssetDescriptor.Web, + relation: Relation, + target: AssetDescriptor, + ): Boolean { + val request = buildCheckApiRequest(source, relation, target) + val response = httpClient.safeFetch(request) + val parsed = response?.parseJsonBody { body -> + parseCheckAssetLinksJson(JSONObject(body)) + } + return parsed?.linked == true + } + + override fun listStatements(source: AssetDescriptor.Web): Sequence<Statement> { + val request = buildListApiRequest(source) + val response = httpClient.safeFetch(request) + val parsed = response?.parseJsonBody { body -> + parseListStatementsJson(JSONObject(body)) + } + return parsed?.statements.orEmpty().asSequence() + } + + private fun apiUrlBuilder(path: String) = BASE_URL.toUri().buildUpon() + .encodedPath(path) + .appendQueryParameter("prettyPrint", false.toString()) + .appendQueryParameter("key", apiKey) + + /** + * Returns a [Request] used to check whether the specified (directional) relationship exists + * between the specified source and target assets. + * + * https://developers.google.com/digital-asset-links/reference/rest/v1/assetlinks/check + */ + @VisibleForTesting + internal fun buildCheckApiRequest( + source: AssetDescriptor, + relation: Relation, + target: AssetDescriptor, + ): Request { + val uriBuilder = apiUrlBuilder(CHECK_PATH) + .appendQueryParameter("relation", relation.kindAndDetail) + + // source and target follow the same format, so re-use the query logic for both. + uriBuilder.appendAssetAsQuery(source, "source") + uriBuilder.appendAssetAsQuery(target, "target") + + return Request( + url = uriBuilder.build().toString().sanitizeURL(), + method = Request.Method.GET, + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ) + } + + @VisibleForTesting + internal fun buildListApiRequest(source: AssetDescriptor): Request { + val uriBuilder = apiUrlBuilder(LIST_PATH) + uriBuilder.appendAssetAsQuery(source, "source") + + return Request( + url = uriBuilder.build().toString().sanitizeURL(), + method = Request.Method.GET, + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ) + } + + private fun Uri.Builder.appendAssetAsQuery(asset: AssetDescriptor, prefix: String) { + when (asset) { + is AssetDescriptor.Web -> { + appendQueryParameter("$prefix.web.site", asset.site) + } + is AssetDescriptor.Android -> { + appendQueryParameter("$prefix.androidApp.packageName", asset.packageName) + appendQueryParameter( + "$prefix.androidApp.certificate.sha256Fingerprint", + asset.sha256CertFingerprint, + ) + } + } + } + + companion object { + private const val BASE_URL = "https://digitalassetlinks.googleapis.com" + private const val CHECK_PATH = "/v1/assetlinks:check" + private const val LIST_PATH = "/v1/statements:list" + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/ListStatementsResponse.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/ListStatementsResponse.kt new file mode 100644 index 0000000000..4a9106be9f --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/api/ListStatementsResponse.kt @@ -0,0 +1,56 @@ +/* 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.digitalassetlinks.api + +import mozilla.components.service.digitalassetlinks.AssetDescriptor +import mozilla.components.service.digitalassetlinks.Relation +import mozilla.components.service.digitalassetlinks.Statement +import mozilla.components.support.ktx.android.org.json.asSequence +import org.json.JSONObject + +/** + * @property statements A list of all the matching statements that have been found. + * @property maxAge From serving time, how much longer the response should be considered valid barring further updates. + * Formatted as a duration in seconds with up to nine fractional digits, terminated by 's'. Example: "3.5s". + * @property debug Human-readable message containing information about the response. + */ +data class ListStatementsResponse( + val statements: List<Statement>, + val maxAge: String, + val debug: String, +) + +internal fun parseListStatementsJson(json: JSONObject): ListStatementsResponse { + val statements = json.getJSONArray("statements") + .asSequence { i -> getJSONObject(i) } + .mapNotNull { statementJson -> + val relationString = statementJson.getString("relation") + val relation = Relation.values().find { relationString == it.kindAndDetail } + + val targetJson = statementJson.getJSONObject("target") + val webJson = targetJson.optJSONObject("web") + val androidJson = targetJson.optJSONObject("androidApp") + val target = when { + webJson != null -> AssetDescriptor.Web(site = webJson.getString("site")) + androidJson != null -> AssetDescriptor.Android( + packageName = androidJson.getString("packageName"), + sha256CertFingerprint = androidJson.getJSONObject("certificate") + .getString("sha256Fingerprint"), + ) + else -> null + } + + if (relation != null && target != null) { + Statement(relation, target) + } else { + null + } + } + return ListStatementsResponse( + statements = statements.toList(), + maxAge = json.getString("maxAge"), + debug = json.optString("debugString"), + ) +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Client.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Client.kt new file mode 100644 index 0000000000..cbdcf5e14a --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Client.kt @@ -0,0 +1,20 @@ +/* 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.digitalassetlinks.ext + +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.concept.fetch.Response.Companion.SUCCESS +import java.io.IOException + +internal fun Client.safeFetch(request: Request): Response? { + return try { + val response = fetch(request) + if (response.status == SUCCESS) response else null + } catch (e: IOException) { + null + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Response.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Response.kt new file mode 100644 index 0000000000..36a1d82afc --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/ext/Response.kt @@ -0,0 +1,20 @@ +/* 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.digitalassetlinks.ext + +import mozilla.components.concept.fetch.Response +import org.json.JSONException + +/** + * Safely parse a JSON [Response] returned by an API. + */ +inline fun <T> Response.parseJsonBody(crossinline parser: (String) -> T): T? { + val responseBody = use { body.string() } + return try { + parser(responseBody) + } catch (e: JSONException) { + null + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementApi.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementApi.kt new file mode 100644 index 0000000000..798650af34 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementApi.kt @@ -0,0 +1,143 @@ +/* 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.digitalassetlinks.local + +import androidx.core.net.toUri +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Headers.Names.CONTENT_TYPE +import mozilla.components.concept.fetch.Headers.Values.CONTENT_TYPE_APPLICATION_JSON +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.service.digitalassetlinks.AssetDescriptor +import mozilla.components.service.digitalassetlinks.IncludeStatement +import mozilla.components.service.digitalassetlinks.Relation +import mozilla.components.service.digitalassetlinks.Statement +import mozilla.components.service.digitalassetlinks.StatementListFetcher +import mozilla.components.service.digitalassetlinks.StatementResult +import mozilla.components.service.digitalassetlinks.TIMEOUT +import mozilla.components.service.digitalassetlinks.ext.safeFetch +import mozilla.components.support.ktx.android.org.json.asSequence +import mozilla.components.support.ktx.kotlin.sanitizeURL +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +/** + * Builds a list of statements by sending HTTP requests to the given website. + */ +class StatementApi(private val httpClient: Client) : StatementListFetcher { + + /** + * Lazily list all the statements in the given [source] website. + * If include statements are present, they will be resolved lazily. + */ + override fun listStatements(source: AssetDescriptor.Web): Sequence<Statement> { + val url = source.site.toUri().buildUpon() + .path("/.well-known/assetlinks.json") + .build() + .toString() + return getWebsiteStatementList(url, seenSoFar = mutableSetOf()) + } + + /** + * Recursively download all the website statements. + * @param assetLinksUrl URL to download. + * @param seenSoFar URLs that have been downloaded already. + * Used to prevent infinite loops. + */ + private fun getWebsiteStatementList( + assetLinksUrl: String, + seenSoFar: MutableSet<String>, + ): Sequence<Statement> { + if (assetLinksUrl in seenSoFar) { + return emptySequence() + } else { + seenSoFar.add(assetLinksUrl) + } + + val request = Request( + url = assetLinksUrl.sanitizeURL(), + method = Request.Method.GET, + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ) + val response = httpClient.safeFetch(request)?.let { res -> + val contentTypes = res.headers.getAll(CONTENT_TYPE) + if (contentTypes.any { it.contains(CONTENT_TYPE_APPLICATION_JSON, ignoreCase = true) }) { + res + } else { + res.close() + null + } + } + + val statements = response?.let { parseStatementResponse(response) }.orEmpty() + return sequence<Statement> { + val includeStatements = mutableListOf<IncludeStatement>() + // Yield all statements that have already been downloaded + statements.forEach { statement -> + when (statement) { + is Statement -> yield(statement) + is IncludeStatement -> includeStatements.add(statement) + } + } + // Recursively download include statements + yieldAll( + includeStatements.asSequence().flatMap { + getWebsiteStatementList(it.include, seenSoFar) + }, + ) + } + } + + /** + * Parse the JSON [Response] returned by the website. + */ + private fun parseStatementResponse(response: Response): List<StatementResult> { + val responseBody = response.use { response.body.string() } + return try { + val responseJson = JSONArray(responseBody) + parseStatementListJson(responseJson) + } catch (e: JSONException) { + emptyList() + } + } + + private fun parseStatementListJson(json: JSONArray): List<StatementResult> { + return json.asSequence { i -> getJSONObject(i) } + .flatMap { parseStatementJson(it) } + .toList() + } + + private fun parseStatementJson(json: JSONObject): Sequence<StatementResult> { + val include = json.optString("include") + if (include.isNotEmpty()) { + return sequenceOf(IncludeStatement(include)) + } + + val relationTypes = Relation.values() + val relations = json.getJSONArray("relation") + .asSequence { i -> getString(i) } + .mapNotNull { relation -> relationTypes.find { relation == it.kindAndDetail } } + + return relations.flatMap { relation -> + val target = json.getJSONObject("target") + val assets = when (target.getString("namespace")) { + "web" -> sequenceOf( + AssetDescriptor.Web(site = target.getString("site")), + ) + "android_app" -> { + val packageName = target.getString("package_name") + target.getJSONArray("sha256_cert_fingerprints") + .asSequence { i -> getString(i) } + .map { fingerprint -> AssetDescriptor.Android(packageName, fingerprint) } + } + else -> emptySequence() + } + + assets.map { asset -> Statement(relation, asset) } + } + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementRelationChecker.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementRelationChecker.kt new file mode 100644 index 0000000000..2dc3548f7b --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/main/java/mozilla/components/service/digitalassetlinks/local/StatementRelationChecker.kt @@ -0,0 +1,35 @@ +/* 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.digitalassetlinks.local + +import mozilla.components.service.digitalassetlinks.AssetDescriptor +import mozilla.components.service.digitalassetlinks.Relation +import mozilla.components.service.digitalassetlinks.RelationChecker +import mozilla.components.service.digitalassetlinks.Statement +import mozilla.components.service.digitalassetlinks.StatementListFetcher + +/** + * Checks if a matching relationship is present in a remote statement list. + */ +class StatementRelationChecker( + private val listFetcher: StatementListFetcher, +) : RelationChecker { + + override fun checkRelationship(source: AssetDescriptor.Web, relation: Relation, target: AssetDescriptor): Boolean { + val statements = listFetcher.listStatements(source) + return checkLink(statements, relation, target) + } + + companion object { + + /** + * Check if any of the given [Statement]s are linked to the given [target]. + */ + fun checkLink(statements: Sequence<Statement>, relation: Relation, target: AssetDescriptor) = + statements.any { statement -> + statement.relation == relation && statement.target == target + } + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinderTest.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinderTest.kt new file mode 100644 index 0000000000..32c244286d --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/AndroidAssetFinderTest.kt @@ -0,0 +1,170 @@ +/* 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.digitalassetlinks + +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.Signature +import android.content.pm.SigningInfo +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mock +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class AndroidAssetFinderTest { + + private lateinit var assetFinder: AndroidAssetFinder + private lateinit var packageInfo: PackageInfo + + @Mock lateinit var packageManager: PackageManager + + @Mock lateinit var signingInfo: SigningInfo + + @Before + fun setup() { + assetFinder = spy(AndroidAssetFinder()) + + MockitoAnnotations.openMocks(this) + packageInfo = PackageInfo() + @Suppress("DEPRECATION") + `when`(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(packageInfo) + } + + @Test + fun `test getAndroidAppAsset returns empty list if name not found`() { + @Suppress("DEPRECATION") + `when`(packageManager.getPackageInfo(anyString(), anyInt())) + .thenThrow(PackageManager.NameNotFoundException::class.java) + + assertEquals( + emptyList<AssetDescriptor.Android>(), + assetFinder.getAndroidAppAsset("com.test.app", packageManager).toList(), + ) + } + + @Config(sdk = [Build.VERSION_CODES.P]) + @Test + fun `test getAndroidAppAsset on P SDK`() { + val signature = mock<Signature>() + packageInfo.signingInfo = signingInfo + `when`(signingInfo.hasMultipleSigners()).thenReturn(false) + `when`(signingInfo.signingCertificateHistory).thenReturn(arrayOf(signature, mock())) + doReturn("01:BB:AA:10:30").`when`(assetFinder).getCertificateSHA256Fingerprint(signature) + + assertEquals( + listOf(AssetDescriptor.Android("com.test.app", "01:BB:AA:10:30")), + assetFinder.getAndroidAppAsset("com.test.app", packageManager).toList(), + ) + } + + @Config(sdk = [Build.VERSION_CODES.P]) + @Test + fun `test getAndroidAppAsset with multiple signatures on P SDK`() { + val signature1 = mock<Signature>() + val signature2 = mock<Signature>() + packageInfo.signingInfo = signingInfo + `when`(signingInfo.hasMultipleSigners()).thenReturn(true) + `when`(signingInfo.apkContentsSigners).thenReturn(arrayOf(signature1, signature2)) + doReturn("01:BB:AA:10:30").`when`(assetFinder).getCertificateSHA256Fingerprint(signature1) + doReturn("FF:CC:AA:99:77").`when`(assetFinder).getCertificateSHA256Fingerprint(signature2) + + assertEquals( + listOf( + AssetDescriptor.Android("org.test.app", "01:BB:AA:10:30"), + AssetDescriptor.Android("org.test.app", "FF:CC:AA:99:77"), + ), + assetFinder.getAndroidAppAsset("org.test.app", packageManager).toList(), + ) + } + + @Config(sdk = [Build.VERSION_CODES.P]) + @Test + fun `test getAndroidAppAsset with empty history`() { + packageInfo.signingInfo = signingInfo + `when`(signingInfo.hasMultipleSigners()).thenReturn(false) + `when`(signingInfo.signingCertificateHistory).thenReturn(emptyArray()) + + assertEquals( + emptyList<AssetDescriptor.Android>(), + assetFinder.getAndroidAppAsset("com.test.app", packageManager).toList(), + ) + } + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) + @Suppress("Deprecation") + @Test + fun `test getAndroidAppAsset on deprecated SDK`() { + val signature = mock<Signature>() + packageInfo.signatures = arrayOf(signature) + doReturn("01:BB:AA:10:30").`when`(assetFinder).getCertificateSHA256Fingerprint(signature) + + assertEquals( + listOf(AssetDescriptor.Android("com.test.app", "01:BB:AA:10:30")), + assetFinder.getAndroidAppAsset("com.test.app", packageManager).toList(), + ) + } + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) + @Suppress("Deprecation") + @Test + fun `test getAndroidAppAsset with multiple signatures on deprecated SDK`() { + val signature1 = mock<Signature>() + val signature2 = mock<Signature>() + packageInfo.signatures = arrayOf(signature1, signature2) + doReturn("01:BB:AA:10:30").`when`(assetFinder).getCertificateSHA256Fingerprint(signature1) + doReturn("FF:CC:AA:99:77").`when`(assetFinder).getCertificateSHA256Fingerprint(signature2) + + assertEquals( + listOf( + AssetDescriptor.Android("org.test.app", "01:BB:AA:10:30"), + AssetDescriptor.Android("org.test.app", "FF:CC:AA:99:77"), + ), + assetFinder.getAndroidAppAsset("org.test.app", packageManager).toList(), + ) + } + + @Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) + @Suppress("Deprecation") + @Test + fun `test getAndroidAppAsset is lazily computed`() { + val signature1 = mock<Signature>() + val signature2 = mock<Signature>() + packageInfo.signatures = arrayOf(signature1, signature2) + doReturn("01:BB:AA:10:30").`when`(assetFinder).getCertificateSHA256Fingerprint(signature1) + doReturn("FF:CC:AA:99:77").`when`(assetFinder).getCertificateSHA256Fingerprint(signature2) + + val result = assetFinder.getAndroidAppAsset("android.package", packageManager).first() + assertEquals( + AssetDescriptor.Android("android.package", "01:BB:AA:10:30"), + result, + ) + + verify(assetFinder, times(1)).getCertificateSHA256Fingerprint(any()) + } + + @Test + fun `test byteArrayToHexString`() { + val array = byteArrayOf(0xaa.toByte(), 0xbb.toByte(), 0xcc.toByte(), 0x10, 0x20, 0x30, 0x01, 0x02) + assertEquals( + "AA:BB:CC:10:20:30:01:02", + assetFinder.byteArrayToHexString(array), + ) + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApiTest.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApiTest.kt new file mode 100644 index 0000000000..53418751b7 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/api/DigitalAssetLinksApiTest.kt @@ -0,0 +1,229 @@ +/* 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.digitalassetlinks.api + +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +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.concept.fetch.Response.Companion.SUCCESS +import mozilla.components.service.digitalassetlinks.AssetDescriptor +import mozilla.components.service.digitalassetlinks.Relation.HANDLE_ALL_URLS +import mozilla.components.service.digitalassetlinks.Relation.USE_AS_ORIGIN +import mozilla.components.service.digitalassetlinks.Statement +import mozilla.components.service.digitalassetlinks.TIMEOUT +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.verify + +@RunWith(AndroidJUnit4::class) +class DigitalAssetLinksApiTest { + + private val webAsset = AssetDescriptor.Web(site = "https://mozilla.org") + private val androidAsset = AssetDescriptor.Android( + packageName = "com.mozilla.fenix", + sha256CertFingerprint = "01:23:45:67:89", + ) + private val baseRequest = Request( + url = "https://mozilla.org", + method = Request.Method.GET, + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ) + private val apiKey = "X" + private lateinit var client: Client + private lateinit var api: DigitalAssetLinksApi + + @Before + fun setup() { + client = mock() + api = DigitalAssetLinksApi(client, apiKey) + + doReturn(mockResponse("")).`when`(client).fetch(any()) + } + + @Test + fun `reject for invalid status`() { + val response = mockResponse("").copy(status = 400) + doReturn(response).`when`(client).fetch(any()) + + assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset)) + assertEquals(emptyList<Statement>(), api.listStatements(webAsset).toList()) + } + + @Test + fun `reject check for invalid json`() { + doReturn(mockResponse("")).`when`(client).fetch(any()) + assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, webAsset)) + + doReturn(mockResponse("{}")).`when`(client).fetch(any()) + assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset)) + + doReturn(mockResponse("[]")).`when`(client).fetch(any()) + assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset)) + + doReturn(mockResponse("{\"lnkd\":true}")).`when`(client).fetch(any()) + assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset)) + } + + @Test + fun `reject list for invalid json`() { + val empty = emptyList<Statement>() + + doReturn(mockResponse("")).`when`(client).fetch(any()) + assertEquals(empty, api.listStatements(webAsset).toList()) + + doReturn(mockResponse("{}")).`when`(client).fetch(any()) + assertEquals(empty, api.listStatements(webAsset).toList()) + + doReturn(mockResponse("[]")).`when`(client).fetch(any()) + assertEquals(empty, api.listStatements(webAsset).toList()) + + doReturn(mockResponse("{\"stmt\":[]}")).`when`(client).fetch(any()) + assertEquals(empty, api.listStatements(webAsset).toList()) + } + + @Test + fun `return linked from json`() { + doReturn(mockResponse("{\"linked\":true,\"maxAge\":\"3s\"}")).`when`(client).fetch(any()) + assertTrue(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset)) + + doReturn(mockResponse("{\"linked\":false}\"maxAge\":\"3s\"}")).`when`(client).fetch(any()) + assertFalse(api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset)) + } + + @Test + fun `return empty list if json doesn't match expected format`() { + val jsonPrefix = "{\"statements\":[" + val jsonSuffix = "],\"maxAge\":\"3s\"}" + doReturn(mockResponse(jsonPrefix + jsonSuffix)).`when`(client).fetch(any()) + assertEquals(emptyList<Statement>(), api.listStatements(webAsset).toList()) + + val invalidRelation = """ + { + "source": {"web":{"site": "https://mozilla.org"}}, + "target": {"web":{"site": "https://mozilla.org"}}, + "relation": "not-a-relation" + } + """ + doReturn(mockResponse(jsonPrefix + invalidRelation + jsonSuffix)).`when`(client).fetch(any()) + assertEquals(emptyList<Statement>(), api.listStatements(webAsset).toList()) + + val invalidTarget = """ + { + "source": {"web":{"site": "https://mozilla.org"}}, + "target": {}, + "relation": "delegate_permission/common.use_as_origin" + } + """ + doReturn(mockResponse(jsonPrefix + invalidTarget + jsonSuffix)).`when`(client).fetch(any()) + assertEquals(emptyList<Statement>(), api.listStatements(webAsset).toList()) + } + + @Test + fun `parses json statement list with web target`() { + val webStatement = """ + {"statements": [{ + "source": {"web":{"site": "https://mozilla.org"}}, + "target": {"web":{"site": "https://mozilla.org"}}, + "relation": "delegate_permission/common.use_as_origin" + }], "maxAge": "59s"} + """ + doReturn(mockResponse(webStatement)).`when`(client).fetch(any()) + assertEquals( + listOf( + Statement( + relation = USE_AS_ORIGIN, + target = webAsset, + ), + ), + api.listStatements(webAsset).toList(), + ) + } + + @Test + fun `parses json statement list with android target`() { + val androidStatement = """ + {"statements": [{ + "source": {"web":{"site": "https://mozilla.org"}}, + "target": {"androidApp":{ + "packageName": "com.mozilla.fenix", + "certificate": {"sha256Fingerprint": "01:23:45:67:89"} + }}, + "relation": "delegate_permission/common.handle_all_urls" + }], "maxAge": "2m"} + """ + doReturn(mockResponse(androidStatement)).`when`(client).fetch(any()) + assertEquals( + listOf( + Statement( + relation = HANDLE_ALL_URLS, + target = androidAsset, + ), + ), + api.listStatements(webAsset).toList(), + ) + } + + @Test + fun `passes data in get check request URL for android target`() { + api.checkRelationship(webAsset, USE_AS_ORIGIN, androidAsset) + verify(client).fetch( + baseRequest.copy( + url = "https://digitalassetlinks.googleapis.com/v1/assetlinks:check?" + + "prettyPrint=false&key=X&relation=delegate_permission%2Fcommon.use_as_origin&" + + "source.web.site=${Uri.encode("https://mozilla.org")}&" + + "target.androidApp.packageName=com.mozilla.fenix&" + + "target.androidApp.certificate.sha256Fingerprint=${Uri.encode("01:23:45:67:89")}", + ), + ) + } + + @Test + fun `passes data in get check request URL for web target`() { + api.checkRelationship(webAsset, HANDLE_ALL_URLS, webAsset) + verify(client).fetch( + baseRequest.copy( + url = "https://digitalassetlinks.googleapis.com/v1/assetlinks:check?" + + "prettyPrint=false&key=X&relation=delegate_permission%2Fcommon.handle_all_urls&" + + "source.web.site=${Uri.encode("https://mozilla.org")}&" + + "target.web.site=${Uri.encode("https://mozilla.org")}", + ), + ) + } + + @Test + fun `passes data in get list request URL`() { + api.listStatements(webAsset) + verify(client).fetch( + baseRequest.copy( + url = "https://digitalassetlinks.googleapis.com/v1/statements:list?" + + "prettyPrint=false&key=X&source.web.site=${Uri.encode("https://mozilla.org")}", + ), + ) + } + + private fun mockResponse(data: String) = Response( + url = "", + status = SUCCESS, + headers = MutableHeaders(), + body = mockBody(data), + ) + + private fun mockBody(data: String): Response.Body { + val mockBody: Response.Body = mock() + doReturn(data).`when`(mockBody).string() + return mockBody + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementApiTest.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementApiTest.kt new file mode 100644 index 0000000000..7ebd8ac67e --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementApiTest.kt @@ -0,0 +1,356 @@ +/* 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.digitalassetlinks.local + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Headers.Names.CONTENT_TYPE +import mozilla.components.concept.fetch.Headers.Values.CONTENT_TYPE_APPLICATION_JSON +import mozilla.components.concept.fetch.Headers.Values.CONTENT_TYPE_FORM_URLENCODED +import mozilla.components.concept.fetch.MutableHeaders +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.service.digitalassetlinks.AssetDescriptor +import mozilla.components.service.digitalassetlinks.Relation +import mozilla.components.service.digitalassetlinks.Statement +import mozilla.components.service.digitalassetlinks.StatementListFetcher +import mozilla.components.service.digitalassetlinks.TIMEOUT +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import java.io.ByteArrayInputStream +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class StatementApiTest { + + @Mock private lateinit var httpClient: Client + private lateinit var listFetcher: StatementListFetcher + private val jsonHeaders = MutableHeaders( + CONTENT_TYPE to CONTENT_TYPE_APPLICATION_JSON, + ) + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + listFetcher = StatementApi(httpClient) + } + + @Test + fun `return empty list if request fails`() { + `when`( + httpClient.fetch( + Request( + url = "https://mozilla.org/.well-known/assetlinks.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenThrow(IOException::class.java) + + val source = AssetDescriptor.Web("https://mozilla.org") + assertEquals(emptyList<Statement>(), listFetcher.listStatements(source).toList()) + } + + @Test + fun `return empty list if response does not have status 200`() { + val response = Response( + url = "https://firefox.com/.well-known/assetlinks.json", + status = 201, + headers = jsonHeaders, + body = mock(), + ) + `when`( + httpClient.fetch( + Request( + url = "https://firefox.com/.well-known/assetlinks.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenReturn(response) + + val source = AssetDescriptor.Web("https://firefox.com") + assertEquals(emptyList<Statement>(), listFetcher.listStatements(source).toList()) + } + + @Test + fun `return empty list if response does not have JSON content type`() { + val response = Response( + url = "https://firefox.com/.well-known/assetlinks.json", + status = 200, + headers = MutableHeaders( + CONTENT_TYPE to CONTENT_TYPE_FORM_URLENCODED, + ), + body = mock(), + ) + + `when`( + httpClient.fetch( + Request( + url = "https://firefox.com/.well-known/assetlinks.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenReturn(response) + + val source = AssetDescriptor.Web("https://firefox.com") + assertEquals(emptyList<Statement>(), listFetcher.listStatements(source).toList()) + } + + @Test + fun `return empty list if response is not valid JSON`() { + val response = Response( + url = "http://firefox.com/.well-known/assetlinks.json", + status = 200, + headers = jsonHeaders, + body = stringBody("not-json"), + ) + + `when`( + httpClient.fetch( + Request( + url = "http://firefox.com/.well-known/assetlinks.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenReturn(response) + + val source = AssetDescriptor.Web("http://firefox.com") + assertEquals(emptyList<Statement>(), listFetcher.listStatements(source).toList()) + } + + @Test + fun `return empty list if response is an empty JSON array`() { + val response = Response( + url = "http://firefox.com/.well-known/assetlinks.json", + status = 200, + headers = jsonHeaders, + body = stringBody("[]"), + ) + + `when`( + httpClient.fetch( + Request( + url = "http://firefox.com/.well-known/assetlinks.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenReturn(response) + + val source = AssetDescriptor.Web("http://firefox.com") + assertEquals(emptyList<Statement>(), listFetcher.listStatements(source).toList()) + } + + @Test + fun `parses example asset links file`() { + val response = Response( + url = "http://firefox.com/.well-known/assetlinks.json", + status = 200, + headers = jsonHeaders, + body = stringBody( + """ + [{ + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.use_as_origin" + ], + "target": { + "namespace": "web", + "site": "https://www.google.com" + } + },{ + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "org.digitalassetlinks.sampleapp", + "sha256_cert_fingerprints": [ + "10:39:38:EE:45:37:E5:9E:8E:E7:92:F6:54:50:4F:B8:34:6F:C6:B3:46:D0:BB:C4:41:5F:C3:39:FC:FC:8E:C1" + ] + } + },{ + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "org.digitalassetlinks.sampleapp2", + "sha256_cert_fingerprints": ["AA", "BB"] + } + }] + """, + ), + ) + `when`( + httpClient.fetch( + Request( + url = "http://firefox.com/.well-known/assetlinks.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenReturn(response) + + val source = AssetDescriptor.Web("http://firefox.com") + assertEquals( + listOf( + Statement( + relation = Relation.HANDLE_ALL_URLS, + target = AssetDescriptor.Web("https://www.google.com"), + ), + Statement( + relation = Relation.USE_AS_ORIGIN, + target = AssetDescriptor.Web("https://www.google.com"), + ), + Statement( + relation = Relation.HANDLE_ALL_URLS, + target = AssetDescriptor.Android( + packageName = "org.digitalassetlinks.sampleapp", + sha256CertFingerprint = "10:39:38:EE:45:37:E5:9E:8E:E7:92:F6:54:50:4F:B8:34:6F:C6:B3:46:D0:BB:C4:41:5F:C3:39:FC:FC:8E:C1", + ), + ), + Statement( + relation = Relation.HANDLE_ALL_URLS, + target = AssetDescriptor.Android( + packageName = "org.digitalassetlinks.sampleapp2", + sha256CertFingerprint = "AA", + ), + ), + Statement( + relation = Relation.HANDLE_ALL_URLS, + target = AssetDescriptor.Android( + packageName = "org.digitalassetlinks.sampleapp2", + sha256CertFingerprint = "BB", + ), + ), + ), + listFetcher.listStatements(source).toList(), + ) + } + + @Test + fun `resolves include statements`() { + `when`( + httpClient.fetch( + Request( + url = "http://firefox.com/.well-known/assetlinks.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenReturn( + Response( + url = "http://firefox.com/.well-known/assetlinks.json", + status = 200, + headers = jsonHeaders, + body = stringBody( + """ + [{ + "relation": ["delegate_permission/common.use_as_origin"], + "target": { + "namespace": "web", + "site": "https://www.google.com" + } + },{ + "include": "https://example.com/includedstatements.json" + }] + """, + ), + ), + ) + `when`( + httpClient.fetch( + Request( + url = "https://example.com/includedstatements.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenReturn( + Response( + url = "https://example.com/includedstatements.json", + status = 200, + headers = jsonHeaders, + body = stringBody( + """ + [{ + "relation": ["delegate_permission/common.use_as_origin"], + "target": { + "namespace": "web", + "site": "https://www.example.com" + } + }] + """, + ), + ), + ) + + val source = AssetDescriptor.Web("http://firefox.com") + assertEquals( + listOf( + Statement( + relation = Relation.USE_AS_ORIGIN, + target = AssetDescriptor.Web("https://www.google.com"), + ), + Statement( + relation = Relation.USE_AS_ORIGIN, + target = AssetDescriptor.Web("https://www.example.com"), + ), + ), + listFetcher.listStatements(source).toList(), + ) + } + + @Test + fun `no infinite loops`() { + `when`( + httpClient.fetch( + Request( + url = "http://firefox.com/.well-known/assetlinks.json", + connectTimeout = TIMEOUT, + readTimeout = TIMEOUT, + ), + ), + ).thenReturn( + Response( + url = "http://firefox.com/.well-known/assetlinks.json", + status = 200, + headers = jsonHeaders, + body = stringBody( + """ + [{ + "relation": ["delegate_permission/common.use_as_origin"], + "target": { + "namespace": "web", + "site": "https://example.com" + } + },{ + "include": "http://firefox.com/.well-known/assetlinks.json" + }] + """, + ), + ), + ) + + val source = AssetDescriptor.Web("http://firefox.com") + assertEquals( + listOf( + Statement( + relation = Relation.USE_AS_ORIGIN, + target = AssetDescriptor.Web("https://example.com"), + ), + ), + listFetcher.listStatements(source).toList(), + ) + } + + private fun stringBody(data: String) = Response.Body(ByteArrayInputStream(data.toByteArray())) +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementRelationCheckerTest.kt b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementRelationCheckerTest.kt new file mode 100644 index 0000000000..4498ab56a1 --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/test/java/mozilla/components/service/digitalassetlinks/local/StatementRelationCheckerTest.kt @@ -0,0 +1,89 @@ +/* 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.digitalassetlinks.local + +import mozilla.components.service.digitalassetlinks.AssetDescriptor +import mozilla.components.service.digitalassetlinks.Relation +import mozilla.components.service.digitalassetlinks.Statement +import mozilla.components.service.digitalassetlinks.StatementListFetcher +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StatementRelationCheckerTest { + + @Test + fun `checks list lazily`() { + var numYields = 0 + val target = AssetDescriptor.Web("https://mozilla.org") + val listFetcher = object : StatementListFetcher { + override fun listStatements(source: AssetDescriptor.Web) = sequence { + numYields = 1 + yield( + Statement( + relation = Relation.USE_AS_ORIGIN, + target = target, + ), + ) + numYields = 2 + yield( + Statement( + relation = Relation.USE_AS_ORIGIN, + target = target, + ), + ) + } + } + + val checker = StatementRelationChecker(listFetcher) + assertEquals(0, numYields) + + assertTrue(checker.checkRelationship(mock(), Relation.USE_AS_ORIGIN, target)) + assertEquals(1, numYields) + + // Sanity check that the mock can yield twice + numYields = 0 + listFetcher.listStatements(mock()).toList() + assertEquals(2, numYields) + } + + @Test + fun `fails if relation does not match`() { + val target = AssetDescriptor.Android("com.test", "AA:BB") + val listFetcher = object : StatementListFetcher { + override fun listStatements(source: AssetDescriptor.Web) = sequenceOf( + Statement( + relation = Relation.USE_AS_ORIGIN, + target = target, + ), + ) + } + + val checker = StatementRelationChecker(listFetcher) + assertFalse(checker.checkRelationship(mock(), Relation.HANDLE_ALL_URLS, target)) + } + + @Test + fun `fails if target does not match`() { + val target = AssetDescriptor.Web("https://mozilla.org") + val listFetcher = object : StatementListFetcher { + override fun listStatements(source: AssetDescriptor.Web) = sequenceOf( + Statement( + relation = Relation.HANDLE_ALL_URLS, + target = AssetDescriptor.Web("https://mozilla.com"), + ), + Statement( + relation = Relation.HANDLE_ALL_URLS, + target = AssetDescriptor.Web("http://mozilla.org"), + ), + ) + } + + val checker = StatementRelationChecker(listFetcher) + assertFalse(checker.checkRelationship(mock(), Relation.HANDLE_ALL_URLS, target)) + } +} diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/service/digitalassetlinks/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/digitalassetlinks/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/robolectric.properties b/mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/service/digitalassetlinks/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/mobile/android/android-components/components/service/firefox-accounts/README.md b/mobile/android/android-components/components/service/firefox-accounts/README.md new file mode 100644 index 0000000000..dc78194d78 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/README.md @@ -0,0 +1,317 @@ +# [Android Components](../../../README.md) > Service > Firefox Accounts (FxA) + +A library for integrating with Firefox Accounts. + +## Motivation + +The **Firefox Accounts Android Component** provides both low and high level accounts functionality. + +At a low level, there is direct interaction with the accounts system: +* Obtain scoped OAuth tokens that can be used to access the user's data in Mozilla-hosted services like Firefox Sync +* Fetch client-side scoped keys needed for end-to-end encryption of that data +* Fetch a user's profile to personalize the application + +At a high level, there is an Account Manager: +* Handles account state management and persistence +* Abstracts away OAuth details, handling scopes, token caching, recovery, etc. Application can still specify custom scopes if needed +* Integrates with FxA device management, automatically creating and destroying device records as appropriate +* (optionally) Provides Send Tab integration - allows sending and receiving tabs within the Firefox Account ecosystem +* (optionally) Provides Firefox Sync integration + +Sample applications: +* [accounts sample app](https://github.com/mozilla-mobile/android-components/tree/main/samples/firefox-accounts), demonstrates how to use low level APIs +* [sync app](https://github.com/mozilla-mobile/android-components/tree/main/samples/sync), demonstrates a high level accounts integration, complete with syncing multiple data stores + +Useful companion components: +* [feature-accounts](https://github.com/mozilla-mobile/android-components/tree/main/components/feature/accounts), provides a `tabs` integration on top of `FxaAccountManager`, to handle display of web sign-in UI. +* [browser-storage-sync](https://github.com/mozilla-mobile/android-components/tree/main/components/browser/storage-sync), provides data storage layers compatible with Firefox Sync. + +## Before using this component +Products sending telemetry and using this component *must request* a data-review following [this process](https://wiki.mozilla.org/Firefox/Data_Collection). +This component provides data collection using the [Glean SDK](https://mozilla.github.io/glean/book/index.html). +The list of metrics being collected is available in the [metrics documentation](../../support/sync-telemetry/docs/metrics.md). + +## 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-firefox-accounts:{latest-version}" +``` + +### High level APIs, recommended for most applications + +Below is an example of how to integrate most of the common functionality exposed by `FxaAccountManager`. +Additionally, see `feature-accounts` + +```kotlin +// Make the two "syncable" stores accessible to account manager's sync machinery. +GlobalSyncableStoreProvider.configureStore(SyncEngine.History to historyStorage) +GlobalSyncableStoreProvider.configureStore(SyncEngine.Bookmarks to bookmarksStorage) + +val accountManager = FxaAccountManager( + context = this, + serverConfig = ServerConfig.release(CLIENT_ID, REDIRECT_URL), + deviceConfig = DeviceConfig( + name = "Sample app", + type = DeviceType.MOBILE, + capabilities = setOf(DeviceCapability.SEND_TAB) + ), + syncConfig = SyncConfig(setOf(SyncEngine.History, SyncEngine.Bookmarks), syncPeriodInMinutes = 15L) +) + +// Observe changes to the account and profile. +accountManager.register(accountObserver, owner = this, autoPause = true) + +// Observe sync state changes. +accountManager.registerForSyncEvents(syncObserver, owner = this, autoPause = true) + +// Observe incoming account events (e.g. when another device connects or +// disconnects to/from the account, SEND_TAB commands from other devices, etc). +// Note that since the device is configured with a SEND_TAB capability, device constellation will be +// automatically updated during any account initialization flow (restore, login, sign-up, recovery). +// It is up to the application to keep it up-to-date beyond that. +// See `account.deviceConstellation().refreshDeviceStateAsync()`. +accountManager.registerForAccountEvents(accountEventsObserver, owner = this, autoPause = true) + +// Now that all of the observers we care about are registered, kick off the account manager. +// If we're already authenticated +launch { accountManager.initAsync().await() } + +// 'Sync Now' button binding. +findViewById<View>(R.id.buttonSync).setOnClickListener { + accountManager.syncNowAsync(SyncReason.User) +} + +// 'Sign-in' button binding. +findViewById<View>(R.id.buttonSignIn).setOnClickListener { + launch { + val authUrl = accountManager.beginAuthenticationAsync().await() + authUrl?.let { openWebView(it) } + } +} + +// 'Sign-out' button binding +findViewById<View>(R.id.buttonLogout).setOnClickListener { + launch { + accountManager.logoutAsync().await() + } +} + +// 'Disable periodic sync' button binding +findViewById<View>(R.id.disablePeriodicSync).setOnClickListener { + launch { + accountManager.setSyncConfigAsync( + SyncConfig(setOf(SyncReason.History, SyncReason.Bookmarks) + ).await() + } +} + +// 'Enable periodic sync' button binding +findViewById<View>(R.id.enablePeriodicSync).setOnClickListener { + launch { + accountManager.setSyncConfigAsync( + SyncConfig(setOf(SyncReason.History, SyncReason.Bookmarks), syncPeriodInMinutes = 60L) + ).await() + } +} + +// Globally disabled syncing an engine - this affects all Firefox Sync clients. +findViewById<View>(R.id.globallyDisableHistoryEngine).setOnClickListener { + SyncEnginesStorage.setStatus(SyncEngine.History, false) + accountManager.syncNowAsync(SyncReason.EngineChange) +} + +// Get current status of SyncEngines. Note that this may change after every sync, as other Firefox Sync clients can change it. +val engineStatusMap = SyncEnginesStorage.getStatus() // type is: Map<SyncEngine, Boolean> + +// This is expected to be called from the webview/geckoview integration, which intercepts page loads and gets +// 'code' and 'state' out of the 'successful sign-in redirect' url. +fun onLoginComplete(code: String, state: String) { + launch { + accountManager.finishAuthenticationAsync(code, state).await() + } +} + +// Observe changes to account state. +val accountObserver = object : AccountObserver { + override fun onLoggedOut() = launch { + // handle logging-out in the UI + } + + override fun onAuthenticationProblems() = launch { + // prompt user to re-authenticate + } + + override fun onAuthenticated(account: OAuthAccount) = launch { + // logged-in successfully; display account details + } + + override fun onProfileUpdated(profile: Profile) { + // display ${profile.displayName} and ${profile.email} if desired + } +} + +// Observe changes to sync state. +val syncObserver = object : SyncStatusObserver { + override fun onStarted() = launch { + // sync started running; update some UI to indicate this + } + + override fun onIdle() = launch { + // sync stopped running; update some UI to indicate this + } + + override fun onError(error: Exception?) = launch { + // sync encountered an error; optionally indicate this in the UI + } +} + +// Observe incoming account events. +val accountEventsObserver = object : AccountEventsObserver { + override fun onEvents(event: List<AccountEvent>) { + // device received some commands; for example, here's how you can process incoming Send Tab commands: + commands + .filter { it is AccountEvent.CommandReceived } + .map { it.command } + .filter { it is DeviceCommandIncoming.TabReceived } + .forEach { + val tabReceivedCommand = it as DeviceCommandIncoming.TabReceived + val fromDeviceName = tabReceivedCommand.from?.displayName + showNotification("Tab ${tab.title}, received from: ${fromDisplayName}", tab.url) + } + // (although note the SendTabFeature makes dealing with these commands + // easier still.) + } +} +``` + +### Low level APIs + +First you need some OAuth information. Generate a `client_id`, `redirectUrl` and find out the scopes for your application. +See the [Firefox Account documentation](https://mozilla.github.io/application-services/docs/accounts/welcome.html) +for that. + +Once you have the OAuth info, you can start adding `FxAClient` to your Android project. +As part of the OAuth flow your application will be opening up a WebView or a Custom Tab. +Currently the SDK does not provide the WebView, you have to write it yourself. + +Create a global `account` object: + +```kotlin +var account: FirefoxAccount? = null +``` + +You will need to save state for FxA in your app, this example just uses `SharedPreferences`. We suggest using the [Android Keystore]( https://developer.android.com/training/articles/keystore) for this data. +Define variables to help save state for FxA: + +```kotlin +val STATE_PREFS_KEY = "fxaAppState" +val STATE_KEY = "fxaState" +``` + +Then you can write the following: + +```kotlin + +account = getAuthenticatedAccount() +if (account == null) { + // Start authentication flow + val config = Config(CONFIG_URL, CLIENT_ID, REDIRECT_URL) + // Some helpers such as Config.release(CLIENT_ID, REDIRECT_URL) + // are also provided for well-known Firefox Accounts servers. + account = FirefoxAccount(config) +} + +fun getAuthenticatedAccount(): FirefoxAccount? { + val savedJSON = getSharedPreferences(FXA_STATE_PREFS_KEY, Context.MODE_PRIVATE).getString(FXA_STATE_KEY, "") + return savedJSON?.let { + try { + FirefoxAccount.fromJSONString(it) + } catch (e: FxaException) { + null + } + } ?: null +} +``` + +The code above checks if you have some existing state for FxA, otherwise it configures it. All asynchronous methods on `FirefoxAccount` are executed on `Dispatchers.IO`'s dedicated thread pool. They return `Deferred` which is Kotlin's non-blocking cancellable Future type. + +Once the configuration is available and an account instance was created, the authentication flow can be started: + +```kotlin +launch { + val url = account.beginOAuthFlow(scopes).await() + openWebView(url) +} +``` + +When spawning the WebView, be sure to override the `OnPageStarted` function to intercept the redirect url and fetch the code + state parameters: + +```kotlin +override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + if (url != null && url.startsWith(redirectUrl)) { + val uri = Uri.parse(url) + val mCode = uri.getQueryParameter("code") + val mState = uri.getQueryParameter("state") + if (mCode != null && mState != null) { + // Pass the code and state parameters back to your main activity + listener?.onLoginComplete(mCode, mState, this@LoginFragment) + } + } + + super.onPageStarted(view, url, favicon) +} +``` + +Finally, complete the OAuth flow, retrieve the profile information, then save your login state once you've gotten valid profile information: + +```kotlin +launch { + // Complete authentication flow + account.completeOAuthFlow(code, state).await() + + // Display profile information + val profile = account.getProfile().await() + txtView.txt = profile.displayName + + // Persist login state + val json = account.toJSONString() + getSharedPreferences(FXA_STATE_PREFS_KEY, Context.MODE_PRIVATE).edit() + .putString(FXA_STATE_KEY, json).apply() +} +``` + +## Automatic sign-in via trusted on-device FxA Auth providers + +If there are trusted FxA auth providers available on the device, and they're signed-in, it's possible +to automatically sign-in into the same account, gaining access to the same data they have access to (e.g. Firefox Sync). + +Currently supported FxA auth providers are: +- Firefox for Android (release, beta and nightly channels) + +`AccountSharing` provides facilities to securely query auth providers for available accounts. It may be used +directly in concert with a low-level `FirefoxAccount.migrateFromSessionTokenAsync`, or via the high-level `FxaAccountManager`: + +```kotlin +val availableAccounts = accountManager.shareableAccounts(context) +// Display a list of accounts to the user, identified by account.email and account.sourcePackage +// Or, pick the first available account. They're sorted in an order of internal preference (release, beta, nightly). +val selectedAccount = availableAccounts[0] +launch { + val result = accountManager.signInWithShareableAccountAsync(selectedAccount).await() + if (result) { + // Successfully signed-into an account. + // accountManager.authenticatedAccount() is the new account. + } else { + // Failed to sign-into an account, either due to bad credentials or networking issues. + } +} +``` + +## 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/firefox-accounts/build.gradle b/mobile/android/android-components/components/service/firefox-accounts/build.gradle new file mode 100644 index 0000000000..7eac84319f --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/build.gradle @@ -0,0 +1,66 @@ +/* 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 + } + + lint { + warningsAsErrors true + abortOnError true + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + consumerProguardFiles 'proguard-rules-consumer.pro' + } + } + + namespace 'mozilla.components.service.firefox.accounts' +} + +dependencies { + // Types defined in concept-sync are part of the public API of this module. + api project(':concept-sync') + api project(':concept-storage') + + // Parts of this dependency are typealiase'd or are otherwise part of this module's public API. + api ComponentsDependencies.mozilla_appservices_fxaclient + implementation ComponentsDependencies.mozilla_appservices_syncmanager + + // Observable is part of public API of the FxaAccountManager. + api project(':support-base') + implementation project(':support-ktx') + implementation project(':support-utils') + implementation project(':lib-dataprotect') + implementation project(':lib-state') + + implementation ComponentsDependencies.kotlin_coroutines + + implementation ComponentsDependencies.androidx_work_runtime + implementation ComponentsDependencies.androidx_lifecycle_process + + testImplementation project(':support-test') + testImplementation project(':support-test-libstate') + testImplementation ComponentsDependencies.androidx_test_core + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.androidx_work_testing + testImplementation ComponentsDependencies.testing_robolectric + testImplementation ComponentsDependencies.testing_coroutines + + testImplementation ComponentsDependencies.mozilla_appservices_full_megazord_forUnitTests + testImplementation ComponentsDependencies.kotlin_reflect +} + +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/firefox-accounts/proguard-rules-consumer.pro b/mobile/android/android-components/components/service/firefox-accounts/proguard-rules-consumer.pro new file mode 100644 index 0000000000..d3456cd17e --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/proguard-rules-consumer.pro @@ -0,0 +1 @@ +# ProGuard rules for consumers of this library. diff --git a/mobile/android/android-components/components/service/firefox-accounts/proguard-rules.pro b/mobile/android/android-components/components/service/firefox-accounts/proguard-rules.pro new file mode 100644 index 0000000000..50e2b38a97 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/proguard-rules.pro @@ -0,0 +1,25 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /Users/sebastian/Library/Android/sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# 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/firefox-accounts/src/main/AndroidManifest.xml b/mobile/android/android-components/components/service/firefox-accounts/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..816719811c --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/firefox-accounts/src/main/java/mozilla/components/service/fxa/AccountStorage.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/AccountStorage.kt new file mode 100644 index 0000000000..37af0f5b76 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/AccountStorage.kt @@ -0,0 +1,267 @@ +/* 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.fxa + +import android.content.Context +import android.content.SharedPreferences +import androidx.annotation.VisibleForTesting +import mozilla.appservices.fxaclient.FxaRustAuthState +import mozilla.components.concept.base.crash.CrashReporting +import mozilla.components.concept.sync.AccountEvent +import mozilla.components.concept.sync.AccountEventsObserver +import mozilla.components.concept.sync.OAuthAccount +import mozilla.components.concept.sync.StatePersistenceCallback +import mozilla.components.lib.dataprotect.SecureAbove22Preferences +import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.base.observer.ObserverRegistry +import java.lang.ref.WeakReference + +const val FXA_STATE_PREFS_KEY = "fxaAppState" +const val FXA_STATE_KEY = "fxaState" + +/** + * Represents state of our account on disk - is it new, or restored? + */ +internal sealed class AccountOnDisk : WithAccount { + data class Restored(val account: OAuthAccount) : AccountOnDisk() { + override fun account() = account + } + data class New(val account: OAuthAccount) : AccountOnDisk() { + override fun account() = account + } +} + +internal interface WithAccount { + fun account(): OAuthAccount +} + +/** + * Knows how to read account from disk (or creating a new instance if there's no account), + * registering necessary watchers. + */ +open class StorageWrapper( + private val accountManager: FxaAccountManager, + accountEventObserverRegistry: ObserverRegistry<AccountEventsObserver>, + private val serverConfig: ServerConfig, + private val crashReporter: CrashReporting? = null, +) { + private class PersistenceCallback( + private val accountManager: WeakReference<FxaAccountManager>, + ) : StatePersistenceCallback { + private val logger = Logger("FxaStatePersistenceCallback") + + override fun persist(data: String) { + val storage = accountManager.get()?.getAccountStorage() + logger.debug("Persisting account state into $storage") + storage?.write(data) + } + } + + private val statePersistenceCallback = PersistenceCallback(WeakReference(accountManager)) + private val accountEventsIntegration = AccountEventsIntegration(accountEventObserverRegistry) + + internal fun account(): AccountOnDisk { + return try { + when (val account = accountManager.getAccountStorage().read()) { + null -> AccountOnDisk.New(obtainAccount()) + else -> AccountOnDisk.Restored(account) + } + } catch (e: FxaPanicException) { + // Don't swallow panics from the underlying library. + throw e + } catch (e: FxaException) { + // Locally corrupt accounts are simply treated as 'absent'. + AccountOnDisk.New(obtainAccount()) + }.also { + watchAccount(it.account()) + } + } + + private fun watchAccount(account: OAuthAccount) { + account.registerPersistenceCallback(statePersistenceCallback) + account.deviceConstellation().register(accountEventsIntegration) + } + + /** + * Exists strictly for testing purposes, allowing tests to specify their own implementation of [OAuthAccount]. + */ + @VisibleForTesting + open fun obtainAccount(): OAuthAccount = FirefoxAccount(serverConfig, crashReporter) +} + +/** + * In the future, this could be an internal account-related events processing layer. + * E.g., once we grow events such as "please logout". + * For now, we just pass everything downstream as-is. + */ +internal class AccountEventsIntegration( + private val listenerRegistry: ObserverRegistry<AccountEventsObserver>, +) : AccountEventsObserver { + private val logger = Logger("AccountEventsIntegration") + + override fun onEvents(events: List<AccountEvent>) { + logger.info("Received events, notifying listeners") + listenerRegistry.notifyObservers { onEvents(events) } + } +} + +internal interface AccountStorage { + @Throws(Exception::class) + fun read(): OAuthAccount? + fun write(accountState: String) + fun clear() +} + +/** + * Account storage layer which uses plaintext storage implementation. + * + * Migration from [SecureAbove22AccountStorage] will happen upon initialization, + * unless disabled via [migrateFromSecureStorage]. + */ +@SuppressWarnings("TooGenericExceptionCaught") +internal class SharedPrefAccountStorage( + val context: Context, + private val crashReporter: CrashReporting? = null, + migrateFromSecureStorage: Boolean = true, +) : AccountStorage { + internal val logger = Logger("mozac/SharedPrefAccountStorage") + + init { + if (migrateFromSecureStorage) { + // In case we switched from SecureAbove22AccountStorage to this implementation, migrate persisted account + // and clear out the old storage layer. + val secureStorage = SecureAbove22AccountStorage( + context, + crashReporter, + migrateFromPlaintextStorage = false, + ) + try { + secureStorage.read()?.let { secureAccount -> + this.write(secureAccount.toJSONString()) + secureStorage.clear() + } + } catch (e: Exception) { + // Certain devices crash on various Keystore exceptions. While trying to migrate + // to use the plaintext storage we don't want to crash if we can't access secure + // storage, and just catch the errors. + logger.error("Migrating from secure storage failed", e) + } + } + } + + /** + * @throws FxaException if JSON failed to parse into a [FirefoxAccount]. + */ + @Throws(FxaException::class) + override fun read(): OAuthAccount? { + val savedJSON = accountPreferences().getString(FXA_STATE_KEY, null) + ?: return null + + // May throw a generic FxaException if it fails to process saved JSON. + val account = FirefoxAccount.fromJSONString(savedJSON, crashReporter) + val state = account.getAuthState() + if (state != FxaRustAuthState.CONNECTED && crashReporter != null) { + crashReporter.submitCaughtException( + AbnormalAccountStorageEvent.RestoringNonConnectedAccount( + "Restoring account from an unexpected state: $state", + ), + ) + } + return account + } + + override fun write(accountState: String) { + accountPreferences() + .edit() + .putString(FXA_STATE_KEY, accountState) + .apply() + } + + override fun clear() { + accountPreferences() + .edit() + .remove(FXA_STATE_KEY) + .apply() + } + + private fun accountPreferences(): SharedPreferences { + return context.getSharedPreferences(FXA_STATE_PREFS_KEY, Context.MODE_PRIVATE) + } +} + +/** + * A base class for exceptions describing abnormal account storage behaviour. + */ +internal abstract class AbnormalAccountStorageEvent(message: String? = null) : Exception(message) { + /** + * Account state was expected to be present, but it wasn't. + */ + internal class UnexpectedlyMissingAccountState(message: String? = null) : AbnormalAccountStorageEvent(message) + internal class RestoringNonConnectedAccount(message: String? = null) : AbnormalAccountStorageEvent(message) +} + +/** + * Account storage layer which uses encrypted-at-rest storage implementation for supported API levels (23+). + * On older API versions account state is stored in plaintext. + * + * Migration from [SharedPrefAccountStorage] will happen upon initialization, + * unless disabled via [migrateFromPlaintextStorage]. + */ +internal class SecureAbove22AccountStorage( + context: Context, + private val crashReporter: CrashReporting? = null, + migrateFromPlaintextStorage: Boolean = true, +) : AccountStorage { + companion object { + private const val STORAGE_NAME = "fxaStateAC" + private const val KEY_ACCOUNT_STATE = "fxaState" + private const val PREF_NAME = "fxaStatePrefAC" + private const val PREF_KEY_HAS_STATE = "fxaStatePresent" + } + + private val store = SecureAbove22Preferences(context, STORAGE_NAME) + + // Prefs are used here to keep track of abnormal storage behaviour - namely, account state disappearing without + // being cleared first through this class. Note that clearing application data will clear both 'store' and 'prefs'. + private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + + init { + if (migrateFromPlaintextStorage) { + // In case we switched from SharedPrefAccountStorage to this implementation, migrate persisted account + // and clear out the old storage layer. + val plaintextStorage = SharedPrefAccountStorage(context, migrateFromSecureStorage = false) + plaintextStorage.read()?.let { plaintextAccount -> + this.write(plaintextAccount.toJSONString()) + plaintextStorage.clear() + } + } + } + + /** + * @throws FxaException if JSON failed to parse into a [FirefoxAccount]. + */ + @Throws(FxaException::class) + override fun read(): OAuthAccount? { + return store.getString(KEY_ACCOUNT_STATE).also { + // If account state is missing, but we expected it to be present, report an exception. + if (it == null && prefs.getBoolean(PREF_KEY_HAS_STATE, false)) { + crashReporter?.submitCaughtException(AbnormalAccountStorageEvent.UnexpectedlyMissingAccountState()) + // Clear prefs to make sure we only submit this exception once. + prefs.edit().clear().apply() + } + }?.let { FirefoxAccount.fromJSONString(it, crashReporter) } + } + + override fun write(accountState: String) { + store.putString(KEY_ACCOUNT_STATE, accountState) + prefs.edit().putBoolean(PREF_KEY_HAS_STATE, true).apply() + } + + override fun clear() { + store.clear() + prefs.edit().clear().apply() + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Config.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Config.kt new file mode 100644 index 0000000000..bcb7ebd5e4 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Config.kt @@ -0,0 +1,90 @@ +/* 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.fxa + +import mozilla.components.service.fxa.sync.GlobalSyncableStoreProvider + +typealias ServerConfig = mozilla.appservices.fxaclient.FxaConfig +typealias Server = mozilla.appservices.fxaclient.FxaServer + +/** + * @property periodMinutes How frequently periodic sync should happen. + * @property initialDelayMinutes What should the initial delay for the periodic sync be. + */ +data class PeriodicSyncConfig( + val periodMinutes: Int = 240, + val initialDelayMinutes: Int = 5, +) + +/** + * Configuration for sync. + * + * @property supportedEngines A set of supported sync engines, exposed via [GlobalSyncableStoreProvider]. + * @property periodicSyncConfig Optional configuration for running sync periodically. + * Periodic sync is disabled if this is `null`. + */ +data class SyncConfig( + val supportedEngines: Set<SyncEngine>, + val periodicSyncConfig: PeriodicSyncConfig?, +) + +/** + * Describes possible sync engines that device can support. + * + * @property nativeName Internally, Rust SyncManager represents engines as strings. Forward-compatibility + * with new engines is one of the reasons for this. E.g. during any sync, an engine may appear that we + * do not know about. At the public API level, we expose a concrete [SyncEngine] type to allow for more + * robust integrations. We do not expose "unknown" engines via our public API, but do handle them + * internally (by persisting their enabled/disabled status). + * + * [nativeName] must match engine strings defined in the sync15 crate, e.g. https://github.com/mozilla/application-services/blob/main/components/sync15/src/state.rs#L23-L38 + * + * @property nativeName Name of the corresponding Sync1.5 collection. +*/ +sealed class SyncEngine(val nativeName: String) { + // NB: When adding new types, make sure to think through implications for the SyncManager. + // See https://github.com/mozilla-mobile/android-components/issues/4557 + + /** + * A history engine. + */ + object History : SyncEngine("history") + + /** + * A bookmarks engine. + */ + object Bookmarks : SyncEngine("bookmarks") + + /** + * A 'logins/passwords' engine. + */ + object Passwords : SyncEngine("passwords") + + /** + * A remote tabs engine. + */ + object Tabs : SyncEngine("tabs") + + /** + * A credit cards engine. + */ + object CreditCards : SyncEngine("creditcards") + + /** + * An addresses engine. + */ + object Addresses : SyncEngine("addresses") + + /** + * An engine that's none of the above, described by [name]. + */ + data class Other(val name: String) : SyncEngine(name) + + /** + * This engine is used internally, but hidden from the public API because we don't fully support + * this data type right now. + */ + internal object Forms : SyncEngine("forms") +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Exceptions.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Exceptions.kt new file mode 100644 index 0000000000..dc8f6f6a80 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Exceptions.kt @@ -0,0 +1,107 @@ +/* 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.fxa + +/** + * High-level exception class for the exceptions thrown in the Rust library. + */ +typealias FxaException = mozilla.appservices.fxaclient.FxaException + +/** + * Thrown on a network error. + */ +typealias FxaNetworkException = mozilla.appservices.fxaclient.FxaException.Network + +/** + * Thrown when the Rust library hits an assertion or panic (this is always a bug). + */ +typealias FxaPanicException = mozilla.appservices.fxaclient.FxaException.Panic + +/** + * Thrown when the operation requires additional authorization. + */ +typealias FxaUnauthorizedException = mozilla.appservices.fxaclient.FxaException.Authentication + +/** + * Thrown when we try opening paring link from a Firefox configured to use a different content server + */ +typealias FxaOriginMismatchException = mozilla.appservices.fxaclient.FxaException.OriginMismatch + +/** + * Thrown if the application attempts to complete an OAuth flow when no OAuth flow has been + * initiated. This may indicate a user who navigated directly to the OAuth `redirect_uri` for the + * application. + */ +typealias FxaNoExistingAuthFlow = mozilla.appservices.fxaclient.FxaException.NoExistingAuthFlow + +/** + * Thrown when a scoped key was missing in the server response when requesting the OLD_SYNC scope. + */ +typealias FxaSyncScopedKeyMissingException = + mozilla.appservices.fxaclient.FxaException.SyncScopedKeyMissingInServerResponse + +/** + * Thrown when the Rust library hits an unexpected error that isn't a panic. + * This may indicate library misuse, network errors, etc. + */ +typealias FxaUnspecifiedException = mozilla.appservices.fxaclient.FxaException.Other + +/** + * @return 'true' if this exception should be re-thrown and eventually crash the app. + */ +fun FxaException.shouldPropagate(): Boolean { + return when (this) { + // Throw on panics + is FxaPanicException -> true + // Don't throw for recoverable errors. + is FxaNetworkException, + is FxaUnauthorizedException, + is FxaUnspecifiedException, + is FxaOriginMismatchException, + is FxaNoExistingAuthFlow, + -> false + // Throw on newly encountered exceptions. + // If they're actually recoverable and you see them in crash reports, update this check. + else -> true + } +} + +/** + * Exceptions related to the account manager. + */ +sealed class AccountManagerException(message: String) : Exception(message) { + /** + * Hit a circuit-breaker during auth recovery flow. + * @param operation An operation which triggered an auth recovery flow that hit a circuit breaker. + */ + class AuthRecoveryCircuitBreakerException(operation: String) : AccountManagerException( + "Auth recovery circuit breaker triggered by: $operation", + ) + + /** + * Unexpectedly encountered an access token without a key. + * @param operation An operation which triggered this state. + */ + class MissingKeyFromSyncScopedAccessToken(operation: String) : AccountManagerException( + "Encountered an access token without a key: $operation", + ) + + /** + * Failure when running side effects to complete the authentication process. + */ + class AuthenticationSideEffectsFailed : AccountManagerException( + "Failure when running side effects to complete authentication", + ) +} + +/** + * FxaException wrapper easily identifying it as the result of a failed operation of sending tabs. + */ +class SendCommandException(fxaException: FxaException) : Exception(fxaException) + +/** + * Thrown if we saw a keyed access token without a key (e.g. obtained for SCOPE_SYNC). + */ +internal class AccessTokenUnexpectedlyWithoutKey : Exception() diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FirefoxAccount.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FirefoxAccount.kt new file mode 100644 index 0000000000..7fc31785e3 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FirefoxAccount.kt @@ -0,0 +1,258 @@ +/* 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.fxa + +import android.net.Uri +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.plus +import kotlinx.coroutines.withContext +import mozilla.appservices.fxaclient.FxaClient +import mozilla.components.concept.base.crash.CrashReporting +import mozilla.components.concept.sync.AuthFlowUrl +import mozilla.components.concept.sync.DeviceConstellation +import mozilla.components.concept.sync.FxAEntryPoint +import mozilla.components.concept.sync.OAuthAccount +import mozilla.components.concept.sync.StatePersistenceCallback +import mozilla.components.support.base.log.logger.Logger + +typealias PersistCallback = mozilla.appservices.fxaclient.FxaClient.PersistCallback + +/** + * FirefoxAccount represents the authentication state of a client. + */ +class FirefoxAccount internal constructor( + private val inner: FxaClient, + crashReporter: CrashReporting? = null, +) : OAuthAccount { + private val job = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.IO) + job + + private val logger = Logger("FirefoxAccount") + + /** + * Why this exists: in the `init` block below you'll notice that we register a persistence callback + * as soon as we initialize this object. Essentially, we _always_ have a persistence callback + * registered with [FxaClient]. However, our own lifecycle is such that we will not know + * how to actually persist account state until sometime after this object has been created. + * Currently, we're expecting [FxaAccountManager] to configure a real callback. + * This wrapper exists to facilitate that flow of events. + */ + private class WrappingPersistenceCallback : PersistCallback { + private val logger = Logger("WrappingPersistenceCallback") + + @Volatile + private var persistenceCallback: StatePersistenceCallback? = null + + fun setCallback(callback: StatePersistenceCallback) { + logger.debug("Setting persistence callback") + persistenceCallback = callback + } + + override fun persist(data: String) { + val callback = persistenceCallback + + if (callback == null) { + logger.warn("FxaClient tried persist state, but persistence callback is not set") + } else { + logger.debug("Logging state to $callback") + callback.persist(data) + } + } + } + + private var persistCallback = WrappingPersistenceCallback() + private val deviceConstellation = FxaDeviceConstellation(inner, scope, crashReporter) + + init { + inner.registerPersistCallback(persistCallback) + } + + /** + * Construct a FirefoxAccount from a [Config], a clientId, and a redirectUri. + * + * @param crashReporter A crash reporter instance. + * + * Note that it is not necessary to `close` the Config if this constructor is used (however + * doing so will not cause an error). + */ + constructor( + config: ServerConfig, + crashReporter: CrashReporting? = null, + ) : this(FxaClient(config), crashReporter) + + override fun close() { + job.cancel() + inner.close() + } + + override fun registerPersistenceCallback(callback: StatePersistenceCallback) { + logger.info("Registering persistence callback") + persistCallback.setCallback(callback) + } + + internal fun getAuthState() = inner.getAuthState() + + override suspend fun beginOAuthFlow( + scopes: Set<String>, + entryPoint: FxAEntryPoint, + ) = withContext(scope.coroutineContext) { + handleFxaExceptions(logger, "begin oauth flow", { null }) { + val url = inner.beginOAuthFlow(scopes.toTypedArray(), entryPoint.entryName) + val state = Uri.parse(url).getQueryParameter("state")!! + AuthFlowUrl(state, url) + } + } + + override suspend fun beginPairingFlow( + pairingUrl: String, + scopes: Set<String>, + entryPoint: FxAEntryPoint, + ) = withContext(scope.coroutineContext) { + // Eventually we should specify this as a param here, but for now, let's + // use a generic value (it's used only for server-side telemetry, so the + // actual value doesn't matter much) + handleFxaExceptions(logger, "begin oauth pairing flow", { null }) { + val url = inner.beginPairingFlow(pairingUrl, scopes.toTypedArray(), entryPoint.entryName) + val state = Uri.parse(url).getQueryParameter("state")!! + AuthFlowUrl(state, url) + } + } + + override suspend fun getProfile(ignoreCache: Boolean) = withContext(scope.coroutineContext) { + handleFxaExceptions(logger, "getProfile", { null }) { + inner.getProfile(ignoreCache).into() + } + } + + override fun getCurrentDeviceId(): String? { + // This is awkward, yes. Underlying method simply reads some data from in-memory state, and yet it throws + // in case that data isn't there. See https://github.com/mozilla/application-services/issues/2202. + return try { + inner.getCurrentDeviceId() + } catch (e: FxaPanicException) { + throw e + } catch (e: FxaException) { + null + } + } + + override fun getSessionToken(): String? { + return try { + // This is awkward, yes. Underlying method simply reads some data from in-memory state, and yet it throws + // in case that data isn't there. See https://github.com/mozilla/application-services/issues/2202. + inner.getSessionToken() + } catch (e: FxaPanicException) { + throw e + } catch (e: FxaException) { + null + } + } + + override suspend fun getTokenServerEndpointURL() = withContext(scope.coroutineContext) { + handleFxaExceptions(logger, "getTokenServerEndpointURL", { null }) { + inner.getTokenServerEndpointURL() + } + } + + override suspend fun getManageAccountURL(entryPoint: FxAEntryPoint): String? { + return handleFxaExceptions(logger, "getManageAccountURL", { null }) { + inner.getManageAccountURL(entryPoint.entryName) + } + } + + override fun getPairingAuthorityURL(): String { + return inner.getPairingAuthorityURL() + } + + /** + * Fetches the connection success url. + */ + fun getConnectionSuccessURL(): String { + return inner.getConnectionSuccessURL() + } + + override suspend fun completeOAuthFlow(code: String, state: String) = withContext(scope.coroutineContext) { + handleFxaExceptions(logger, "complete oauth flow") { + inner.completeOAuthFlow(code, state) + } + } + + override suspend fun getAccessToken(singleScope: String) = withContext(scope.coroutineContext) { + handleFxaExceptions(logger, "get access token", { null }) { + inner.getAccessToken(singleScope).into() + } + } + + override fun authErrorDetected() { + // fxalib maintains some internal token caches that need to be cleared whenever we + // hit an auth problem. Call below makes that clean-up happen. + inner.clearAccessTokenCache() + } + + override suspend fun checkAuthorizationStatus(singleScope: String) = withContext(scope.coroutineContext) { + // Now that internal token caches are cleared, we can perform a connectivity check. + // Do so by requesting a new access token using an internally-stored "refresh token". + // Success here means that we're still able to connect - our cached access token simply expired. + // Failure indicates that we need to re-authenticate. + try { + inner.getAccessToken(singleScope) + // We were able to obtain a token, so we're in a good authorization state. + true + } catch (e: FxaUnauthorizedException) { + // We got back a 401 while trying to obtain a new access token, which means our refresh + // token is also in a bad state. We need re-authentication for the tested scope. + false + } catch (e: FxaPanicException) { + // Re-throw any panics we may encounter. + throw e + } catch (e: FxaException) { + // On any other FxaExceptions (networking, etc) we have to return an indeterminate result. + null + } + // Re-throw all other exceptions. + } + + override suspend fun disconnect() = withContext(scope.coroutineContext) { + // TODO can this ever throw FxaUnauthorizedException? would that even make sense? or is that a bug? + handleFxaExceptions(logger, "disconnect", { false }) { + inner.disconnect() + true + } + } + + override fun deviceConstellation(): DeviceConstellation { + return deviceConstellation + } + + override fun toJSONString(): String = inner.toJSONString() + + companion object { + /** + * Restores the account's authentication state from a JSON string produced by + * [FirefoxAccount.toJSONString]. + * + * @param crashReporter object used for logging caught exceptions + * + * @param persistCallback This callback will be called every time the [FirefoxAccount] + * internal state has mutated. + * The FirefoxAccount instance can be later restored using the + * [FirefoxAccount.fromJSONString]` class method. + * It is the responsibility of the consumer to ensure the persisted data + * is saved in a secure location, as it can contain Sync Keys and + * OAuth tokens. + * + * @return [FirefoxAccount] representing the authentication state + */ + fun fromJSONString( + json: String, + crashReporter: CrashReporting?, + persistCallback: PersistCallback? = null, + ): FirefoxAccount { + return FirefoxAccount(FxaClient.fromJSONString(json, persistCallback), crashReporter) + } + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceConstellation.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceConstellation.kt new file mode 100644 index 0000000000..99c4fb196f --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceConstellation.kt @@ -0,0 +1,286 @@ +/* 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.fxa + +import android.content.Context +import androidx.annotation.MainThread +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.withContext +import mozilla.appservices.fxaclient.FxaClient +import mozilla.appservices.fxaclient.FxaException +import mozilla.appservices.fxaclient.FxaStateCheckerEvent +import mozilla.appservices.fxaclient.FxaStateCheckerState +import mozilla.appservices.syncmanager.SyncTelemetry +import mozilla.components.concept.base.crash.CrashReporting +import mozilla.components.concept.sync.AccountEvent +import mozilla.components.concept.sync.AccountEventsObserver +import mozilla.components.concept.sync.AuthType +import mozilla.components.concept.sync.ConstellationState +import mozilla.components.concept.sync.Device +import mozilla.components.concept.sync.DeviceCommandOutgoing +import mozilla.components.concept.sync.DeviceConfig +import mozilla.components.concept.sync.DeviceConstellation +import mozilla.components.concept.sync.DeviceConstellationObserver +import mozilla.components.concept.sync.DevicePushSubscription +import mozilla.components.concept.sync.ServiceResult +import mozilla.components.service.fxa.manager.AppServicesStateMachineChecker +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.base.observer.Observable +import mozilla.components.support.base.observer.ObserverRegistry + +internal sealed class FxaDeviceConstellationException(message: String? = null) : Exception(message) { + /** + * Failure while ensuring device capabilities. + */ + class EnsureCapabilitiesFailed(message: String? = null) : FxaDeviceConstellationException(message) +} + +/** + * Provides an implementation of [DeviceConstellation] backed by a [FxaClient + */ +class FxaDeviceConstellation( + private val account: FxaClient, + private val scope: CoroutineScope, + @get:VisibleForTesting + internal val crashReporter: CrashReporting? = null, +) : DeviceConstellation, Observable<AccountEventsObserver> by ObserverRegistry() { + private val logger = Logger("FxaDeviceConstellation") + + private val deviceObserverRegistry = ObserverRegistry<DeviceConstellationObserver>() + + @Volatile + private var constellationState: ConstellationState? = null + + override fun state(): ConstellationState? = constellationState + + @VisibleForTesting + internal enum class DeviceFinalizeAction { + Initialize, + EnsureCapabilities, + None, + } + + @Suppress("ComplexMethod") + @Throws(FxaPanicException::class) + override suspend fun finalizeDevice( + authType: AuthType, + config: DeviceConfig, + ): ServiceResult = withContext(scope.coroutineContext) { + val finalizeAction = when (authType) { + AuthType.Signin, + AuthType.Signup, + AuthType.Pairing, + is AuthType.OtherExternal, + AuthType.MigratedCopy, + -> DeviceFinalizeAction.Initialize + AuthType.Existing, + AuthType.MigratedReuse, + -> DeviceFinalizeAction.EnsureCapabilities + AuthType.Recovered -> DeviceFinalizeAction.None + } + + if (finalizeAction == DeviceFinalizeAction.None) { + ServiceResult.Ok + } else { + val capabilities = config.capabilities.map { it.into() }.toSet() + // Note: sending the event for the result to the the state machine checker is split + // between here and `FxaAccountManager` + // - This function reports successes and auth failures, since it's the only one that + // knows if `initializeDevice()` or `EnsureDeviceCapabilities()` was called. + // - `FxaAccountManager` reports other failures, since it runs this code inside + // `withServiceRetries` so it's the only one that knows if the call will be retried + if (finalizeAction == DeviceFinalizeAction.Initialize) { + try { + AppServicesStateMachineChecker.checkInternalState(FxaStateCheckerState.InitializeDevice) + account.initializeDevice(config.name, config.type.into(), capabilities) + AppServicesStateMachineChecker.handleInternalEvent(FxaStateCheckerEvent.InitializeDeviceSuccess) + ServiceResult.Ok + } catch (e: FxaPanicException) { + throw e + } catch (e: FxaUnauthorizedException) { + AppServicesStateMachineChecker.handleInternalEvent(FxaStateCheckerEvent.CallError) + ServiceResult.AuthError + } catch (e: FxaException) { + ServiceResult.OtherError + } + } else { + try { + AppServicesStateMachineChecker.checkInternalState(FxaStateCheckerState.EnsureDeviceCapabilities) + account.ensureCapabilities(capabilities) + AppServicesStateMachineChecker.handleInternalEvent( + FxaStateCheckerEvent.EnsureDeviceCapabilitiesSuccess, + ) + ServiceResult.Ok + } catch (e: FxaPanicException) { + throw e + } catch (e: FxaUnauthorizedException) { + AppServicesStateMachineChecker.handleInternalEvent(FxaStateCheckerEvent.EnsureCapabilitiesAuthError) + // Unless we've added a new capability, in practice 'ensureCapabilities' isn't + // actually expected to do any work: everything should have been done by initializeDevice. + // So if it did, and failed, let's report this so that we're aware of this! + // See https://github.com/mozilla-mobile/android-components/issues/8164 + crashReporter?.submitCaughtException( + FxaDeviceConstellationException.EnsureCapabilitiesFailed(e.toString()), + ) + ServiceResult.AuthError + } catch (e: FxaException) { + ServiceResult.OtherError + } + } + } + } + + override suspend fun processRawEvent(payload: String) = withContext(scope.coroutineContext) { + handleFxaExceptions(logger, "processing raw commands") { + val events = when (val accountEvent: AccountEvent = account.handlePushMessage(payload).into()) { + is AccountEvent.DeviceCommandIncoming -> account.pollDeviceCommands().map { + AccountEvent.DeviceCommandIncoming(command = it.into()) + } + else -> listOf(accountEvent) + } + processEvents(events) + } + } + + @MainThread + override fun registerDeviceObserver( + observer: DeviceConstellationObserver, + owner: LifecycleOwner, + autoPause: Boolean, + ) { + logger.debug("registering device observer") + deviceObserverRegistry.register(observer, owner, autoPause) + } + + override suspend fun setDeviceName(name: String, context: Context) = withContext(scope.coroutineContext) { + val rename = handleFxaExceptions(logger, "changing device name") { + account.setDeviceDisplayName(name) + } + FxaDeviceSettingsCache(context).updateCachedName(name) + // See the latest device (name) changes after changing it. + + rename && refreshDevices() + } + + override suspend fun setDevicePushSubscription( + subscription: DevicePushSubscription, + ) = withContext(scope.coroutineContext) { + handleFxaExceptions(logger, "updating device push subscription") { + account.setDevicePushSubscription( + subscription.endpoint, + subscription.publicKey, + subscription.authKey, + ) + } + } + + override suspend fun sendCommandToDevice( + targetDeviceId: String, + outgoingCommand: DeviceCommandOutgoing, + ) = withContext(scope.coroutineContext) { + val result = handleFxaExceptions(logger, "sending device command", { error -> error }) { + when (outgoingCommand) { + is DeviceCommandOutgoing.SendTab -> { + account.sendSingleTab(targetDeviceId, outgoingCommand.title, outgoingCommand.url) + val errors: List<Throwable> = SyncTelemetry.processFxaTelemetry(account.gatherTelemetry()) + for (error in errors) { + crashReporter?.submitCaughtException(error) + } + } + else -> logger.debug("Skipped sending unsupported command type: $outgoingCommand") + } + null + } + + if (result != null) { + when (result) { + // Don't submit network exceptions to our crash reporter. They're just noise. + is FxaException.Network -> { + logger.warn("Failed to 'sendCommandToDevice' due to a network exception") + } + else -> { + logger.warn("Failed to 'sendCommandToDevice'", result) + crashReporter?.submitCaughtException(SendCommandException(result)) + } + } + + false + } else { + true + } + } + + // Poll for missed commands. Commands are the only event-type that can be + // polled for, although missed commands will be delivered as AccountEvents. + override suspend fun pollForCommands() = withContext(scope.coroutineContext) { + val events = handleFxaExceptions(logger, "polling for device commands", { null }) { + account.pollDeviceCommands().map { AccountEvent.DeviceCommandIncoming(command = it.into()) } + } + + if (events == null) { + false + } else { + processEvents(events) + val errors: List<Throwable> = SyncTelemetry.processFxaTelemetry(account.gatherTelemetry()) + for (error in errors) { + crashReporter?.submitCaughtException(error) + } + true + } + } + + private fun processEvents(events: List<AccountEvent>) { + notifyObservers { onEvents(events) } + } + + override suspend fun refreshDevices(): Boolean { + return withContext(scope.coroutineContext) { + logger.info("Refreshing device list...") + + // Attempt to fetch devices, or bail out on failure. + val allDevices = fetchAllDevices() ?: return@withContext false + + // Find the current device. + val currentDevice = allDevices.find { it.isCurrentDevice }?.also { + // If our current device's push subscription needs to be renewed, then we + // possibly missed some push notifications, so check for that here. + // (This doesn't actually perform the renewal, FxaPushSupportFeature does that.) + if (it.subscription == null || it.subscriptionExpired) { + logger.info("Current device needs push endpoint registration, so checking for missed commands") + pollForCommands() + } + } + + // Filter out the current devices. + val otherDevices = allDevices.filter { !it.isCurrentDevice } + + val newState = ConstellationState(currentDevice, otherDevices) + constellationState = newState + + logger.info("Refreshed device list; saw ${allDevices.size} device(s).") + + // NB: at this point, 'constellationState' might have changed. + // Notify with an immutable, local 'newState' instead. + deviceObserverRegistry.notifyObservers { + logger.info("Notifying observer about constellation updates.") + onDevicesUpdate(newState) + } + + true + } + } + + /** + * Get all devices in the constellation. + * @return A list of all devices in the constellation, or `null` on failure. + */ + private suspend fun fetchAllDevices(): List<Device>? { + return handleFxaExceptions(logger, "fetching all devices", { null }) { + account.getDevices().map { it.into() } + } + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceSettingsCache.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceSettingsCache.kt new file mode 100644 index 0000000000..303326cef9 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/FxaDeviceSettingsCache.kt @@ -0,0 +1,66 @@ +/* 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.fxa + +import android.content.Context +import android.content.SharedPreferences +import mozilla.appservices.sync15.DeviceType +import mozilla.appservices.syncmanager.DeviceSettings +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.base.utils.SharedPreferencesCache +import org.json.JSONObject +import java.lang.IllegalArgumentException +import java.lang.IllegalStateException + +private const val CACHE_NAME = "FxaDeviceSettingsCache" +private const val CACHE_KEY = CACHE_NAME +private const val KEY_FXA_DEVICE_ID = "kid" +private const val KEY_DEVICE_NAME = "syncKey" +private const val KEY_DEVICE_TYPE = "tokenServerUrl" + +/** + * A thin wrapper around [SharedPreferences] which knows how to serialize/deserialize [DeviceSettings]. + * + * This class exists to provide background sync workers with access to [DeviceSettings]. + */ +class FxaDeviceSettingsCache(context: Context) : SharedPreferencesCache<DeviceSettings>(context) { + override val logger = Logger("SyncAuthInfoCache") + override val cacheKey = CACHE_KEY + override val cacheName = CACHE_NAME + + override fun DeviceSettings.toJSON(): JSONObject { + return JSONObject().also { + it.put(KEY_FXA_DEVICE_ID, this.fxaDeviceId) + it.put(KEY_DEVICE_NAME, this.name) + it.put(KEY_DEVICE_TYPE, this.kind.toString()) + } + } + + override fun fromJSON(obj: JSONObject): DeviceSettings { + return DeviceSettings( + fxaDeviceId = obj.getString(KEY_FXA_DEVICE_ID), + name = obj.getString(KEY_DEVICE_NAME), + kind = obj.getString(KEY_DEVICE_TYPE).toDeviceType(), + ) + } + + /** + * @param name New device name to write into the cache. + */ + fun updateCachedName(name: String) { + val cached = getCached() ?: throw IllegalStateException("Trying to update cached value in an empty cache") + setToCache(cached.copy(name = name)) + } + + private fun String.toDeviceType(): DeviceType { + return when (this) { + "DESKTOP" -> DeviceType.DESKTOP + "MOBILE" -> DeviceType.MOBILE + "TABLET" -> DeviceType.TABLET + "VR" -> DeviceType.VR + "TV" -> DeviceType.TV + else -> throw IllegalArgumentException("Unknown device type in cached string: $this") + } + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/SyncAuthInfoCache.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/SyncAuthInfoCache.kt new file mode 100644 index 0000000000..8261c30b9f --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/SyncAuthInfoCache.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.fxa + +import android.content.Context +import android.content.SharedPreferences +import mozilla.components.concept.sync.SyncAuthInfo +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.base.utils.SharedPreferencesCache +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +private const val CACHE_NAME = "SyncAuthInfoCache" +private const val CACHE_KEY = CACHE_NAME +private const val KEY_FXA_ACCESS_TOKEN = "fxaAccessToken" +private const val KEY_FXA_ACCESS_TOKEN_EXPIRES_AT = "fxaAccessTokenExpiresAt" +private const val KEY_KID = "kid" +private const val KEY_SYNC_KEY = "syncKey" +private const val KEY_TOKEN_SERVER_URL = "tokenServerUrl" + +/** + * A thin wrapper around [SharedPreferences] which knows how to serialize/deserialize [SyncAuthInfo]. + * + * This class exists to provide background sync workers with access to [SyncAuthInfo]. + */ +class SyncAuthInfoCache(context: Context) : SharedPreferencesCache<SyncAuthInfo>(context) { + override val logger = Logger("SyncAuthInfoCache") + override val cacheKey = CACHE_KEY + override val cacheName = CACHE_NAME + + override fun SyncAuthInfo.toJSON(): JSONObject { + return JSONObject().also { + it.put(KEY_KID, this.kid) + it.put(KEY_FXA_ACCESS_TOKEN, this.fxaAccessToken) + it.put(KEY_FXA_ACCESS_TOKEN_EXPIRES_AT, this.fxaAccessTokenExpiresAt) + it.put(KEY_SYNC_KEY, this.syncKey) + it.put(KEY_TOKEN_SERVER_URL, this.tokenServerUrl) + } + } + + override fun fromJSON(obj: JSONObject): SyncAuthInfo { + return SyncAuthInfo( + kid = obj.getString(KEY_KID), + fxaAccessToken = obj.getString(KEY_FXA_ACCESS_TOKEN), + fxaAccessTokenExpiresAt = obj.getLong(KEY_FXA_ACCESS_TOKEN_EXPIRES_AT), + syncKey = obj.getString(KEY_SYNC_KEY), + tokenServerUrl = obj.getString(KEY_TOKEN_SERVER_URL), + ) + } + + fun expired(): Boolean { + val expiresAt = getCached()?.fxaAccessTokenExpiresAt ?: return true + val now = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) + + return expiresAt <= now + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/SyncFacts.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/SyncFacts.kt new file mode 100644 index 0000000000..a0233fc5d4 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/SyncFacts.kt @@ -0,0 +1,40 @@ +/* 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.fxa + +import mozilla.components.support.base.Component +import mozilla.components.support.base.facts.Action +import mozilla.components.support.base.facts.Fact +import mozilla.components.support.base.facts.collect + +/** + * Facts emitted for telemetry related to FxA Sync operations. + */ +class SyncFacts { + + /** + * Specific types of telemetry items. + */ + object Items { + const val SYNC_FAILED = "sync_failed" + } +} + +private fun emitSyncFact( + action: Action, + item: String, + value: String? = null, + metadata: Map<String, Any>? = null, +) { + Fact( + Component.SERVICE_FIREFOX_ACCOUNTS, + action, + item, + value, + metadata, + ).collect() +} + +internal fun emitSyncFailedFact() = emitSyncFact(Action.INTERACTION, SyncFacts.Items.SYNC_FAILED) diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Types.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Types.kt new file mode 100644 index 0000000000..737ff3c273 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Types.kt @@ -0,0 +1,251 @@ +/* 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/. */ +@file:SuppressWarnings("MatchingDeclarationName") + +package mozilla.components.service.fxa + +import mozilla.appservices.fxaclient.AccessTokenInfo +import mozilla.appservices.fxaclient.AccountEvent +import mozilla.appservices.fxaclient.Device +import mozilla.appservices.fxaclient.IncomingDeviceCommand +import mozilla.appservices.fxaclient.Profile +import mozilla.appservices.fxaclient.ScopedKey +import mozilla.appservices.fxaclient.TabHistoryEntry +import mozilla.components.concept.sync.AuthType +import mozilla.components.concept.sync.Avatar +import mozilla.components.concept.sync.DeviceCapability +import mozilla.components.concept.sync.DeviceType +import mozilla.components.concept.sync.OAuthScopedKey +import mozilla.components.concept.sync.SyncAuthInfo +import mozilla.appservices.fxaclient.DeviceCapability as RustDeviceCapability +import mozilla.appservices.fxaclient.DevicePushSubscription as RustDevicePushSubscription +import mozilla.appservices.sync15.DeviceType as RustDeviceType + +/** + * Converts a raw 'action' string into an [AuthType] instance. + * Actions come to us from FxA during an OAuth login, either over the WebChannel or via the redirect URL. + */ +fun String?.toAuthType(): AuthType { + return when (this) { + "signin" -> AuthType.Signin + "signup" -> AuthType.Signup + "pairing" -> AuthType.Pairing + // We want to gracefully handle an 'action' we don't know about. + // This also covers the `null` case. + else -> AuthType.OtherExternal(this) + } +} + +/** + * Captures basic OAuth authentication data (code, state) and any additional data FxA passes along. + * @property authType Type of authentication which caused this object to be created. + * @property code OAuth code. + * @property state OAuth state. + * @property declinedEngines An optional list of [SyncEngine]s that user declined to sync. + */ +data class FxaAuthData( + val authType: AuthType, + val code: String, + val state: String, + val declinedEngines: Set<SyncEngine>? = null, +) { + override fun toString(): String { + return "authType: $authType, code: XXX, state: XXX, declinedEngines: $declinedEngines" + } +} + +// The rest of this file describes translations between fxaclient's internal type definitions and analogous +// types defined by concept-sync. It's a little tedious, but ensures decoupling between abstract +// definitions and a concrete implementation. In practice, this means that concept-sync doesn't need +// impose a dependency on fxaclient native library. + +fun AccessTokenInfo.into(): mozilla.components.concept.sync.AccessTokenInfo { + return mozilla.components.concept.sync.AccessTokenInfo( + scope = this.scope, + token = this.token, + key = this.key?.into(), + expiresAt = this.expiresAt, + ) +} + +/** + * Converts a generic [AccessTokenInfo] into a Firefox Sync-friendly [SyncAuthInfo] instance which + * may be used for data synchronization. + * + * @return An [SyncAuthInfo] which is guaranteed to have a sync key. + * @throws IllegalStateException if [AccessTokenInfo] didn't have key information. + */ +fun mozilla.components.concept.sync.AccessTokenInfo.asSyncAuthInfo(tokenServerUrl: String): SyncAuthInfo { + val keyInfo = this.key ?: throw AccessTokenUnexpectedlyWithoutKey() + + return SyncAuthInfo( + kid = keyInfo.kid, + fxaAccessToken = this.token, + fxaAccessTokenExpiresAt = this.expiresAt, + syncKey = keyInfo.k, + tokenServerUrl = tokenServerUrl, + ) +} + +fun ScopedKey.into(): OAuthScopedKey { + return OAuthScopedKey(kid = this.kid, k = this.k, kty = this.kty, scope = this.scope) +} + +fun Profile.into(): mozilla.components.concept.sync.Profile { + return mozilla.components.concept.sync.Profile( + uid = this.uid, + email = this.email, + avatar = this.avatar.let { + Avatar( + url = it, + isDefault = this.isDefaultAvatar, + ) + }, + displayName = this.displayName, + ) +} + +internal fun RustDeviceType.into(): DeviceType { + return when (this) { + RustDeviceType.DESKTOP -> DeviceType.DESKTOP + RustDeviceType.MOBILE -> DeviceType.MOBILE + RustDeviceType.TABLET -> DeviceType.TABLET + RustDeviceType.TV -> DeviceType.TV + RustDeviceType.VR -> DeviceType.VR + RustDeviceType.UNKNOWN -> DeviceType.UNKNOWN + } +} + +/** + * Convert between the native-code DeviceType data class + * and the one from the corresponding a-c concept. + */ +fun DeviceType.into(): RustDeviceType { + return when (this) { + DeviceType.DESKTOP -> RustDeviceType.DESKTOP + DeviceType.MOBILE -> RustDeviceType.MOBILE + DeviceType.TABLET -> RustDeviceType.TABLET + DeviceType.TV -> RustDeviceType.TV + DeviceType.VR -> RustDeviceType.VR + DeviceType.UNKNOWN -> RustDeviceType.UNKNOWN + } +} + +/** + * Convert between the native-code DeviceCapability data class + * and the one from the corresponding a-c concept. + */ +fun DeviceCapability.into(): RustDeviceCapability { + return when (this) { + DeviceCapability.SEND_TAB -> RustDeviceCapability.SEND_TAB + } +} + +/** + * Convert between the a-c concept DeviceCapability class and the corresponding + * native-code DeviceCapability data class. + */ +fun RustDeviceCapability.into(): DeviceCapability { + return when (this) { + RustDeviceCapability.SEND_TAB -> DeviceCapability.SEND_TAB + } +} + +/** + * Convert between the a-c concept DevicePushSubscription class and the corresponding + * native-code DevicePushSubscription data class. + */ +fun mozilla.components.concept.sync.DevicePushSubscription.into(): RustDevicePushSubscription { + return RustDevicePushSubscription( + endpoint = this.endpoint, + authKey = this.authKey, + publicKey = this.publicKey, + ) +} + +/** + * Convert between the native-code DevicePushSubscription data class + * and the one from the corresponding a-c concept. + */ +fun RustDevicePushSubscription.into(): mozilla.components.concept.sync.DevicePushSubscription { + return mozilla.components.concept.sync.DevicePushSubscription( + endpoint = this.endpoint, + authKey = this.authKey, + publicKey = this.publicKey, + ) +} + +fun Device.into(): mozilla.components.concept.sync.Device { + return mozilla.components.concept.sync.Device( + id = this.id, + isCurrentDevice = this.isCurrentDevice, + deviceType = this.deviceType.into(), + displayName = this.displayName, + lastAccessTime = this.lastAccessTime, + subscriptionExpired = this.pushEndpointExpired, + capabilities = this.capabilities.map { it.into() }, + subscription = this.pushSubscription?.into(), + ) +} + +fun mozilla.components.concept.sync.Device.into(): Device { + return Device( + id = this.id, + isCurrentDevice = this.isCurrentDevice, + deviceType = this.deviceType.into(), + displayName = this.displayName, + lastAccessTime = this.lastAccessTime, + pushEndpointExpired = this.subscriptionExpired, + capabilities = this.capabilities.map { it.into() }, + pushSubscription = this.subscription?.into(), + ) +} + +fun TabHistoryEntry.into(): mozilla.components.concept.sync.TabData { + return mozilla.components.concept.sync.TabData( + title = this.title, + url = this.url, + ) +} + +fun mozilla.components.concept.sync.TabData.into(): TabHistoryEntry { + return TabHistoryEntry( + title = this.title, + url = this.url, + ) +} + +fun AccountEvent.into(): mozilla.components.concept.sync.AccountEvent { + return when (this) { + is AccountEvent.CommandReceived -> + mozilla.components.concept.sync.AccountEvent.DeviceCommandIncoming(command = this.command.into()) + is AccountEvent.ProfileUpdated -> + mozilla.components.concept.sync.AccountEvent.ProfileUpdated + is AccountEvent.AccountAuthStateChanged -> + mozilla.components.concept.sync.AccountEvent.AccountAuthStateChanged + is AccountEvent.AccountDestroyed -> + mozilla.components.concept.sync.AccountEvent.AccountDestroyed + is AccountEvent.DeviceConnected -> + mozilla.components.concept.sync.AccountEvent.DeviceConnected(deviceName = this.deviceName) + is AccountEvent.DeviceDisconnected -> + mozilla.components.concept.sync.AccountEvent.DeviceDisconnected( + deviceId = this.deviceId, + isLocalDevice = this.isLocalDevice, + ) + is AccountEvent.Unknown -> mozilla.components.concept.sync.AccountEvent.Unknown + } +} + +fun IncomingDeviceCommand.into(): mozilla.components.concept.sync.DeviceCommandIncoming { + return when (this) { + is IncomingDeviceCommand.TabReceived -> this.into() + } +} + +fun IncomingDeviceCommand.TabReceived.into(): mozilla.components.concept.sync.DeviceCommandIncoming.TabReceived { + return mozilla.components.concept.sync.DeviceCommandIncoming.TabReceived( + from = this.sender?.into(), + entries = this.payload.entries.map { it.into() }, + ) +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Utils.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Utils.kt new file mode 100644 index 0000000000..64322a6205 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/Utils.kt @@ -0,0 +1,153 @@ +/* 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.fxa + +import mozilla.components.concept.sync.AuthFlowUrl +import mozilla.components.concept.sync.FxAEntryPoint +import mozilla.components.concept.sync.OAuthAccount +import mozilla.components.concept.sync.ServiceResult +import mozilla.components.service.fxa.manager.GlobalAccountManager +import mozilla.components.support.base.log.logger.Logger + +/** + * Runs a provided lambda, and if that throws non-panic, non-auth FxA exception, runs [handleErrorBlock]. + * If that lambda throws an FxA auth exception, notifies [authErrorRegistry], and runs [postHandleAuthErrorBlock]. + * + * @param block A lambda to execute which mail fail with an [FxaException]. + * @param postHandleAuthErrorBlock A lambda to execute if [block] failed with [FxaUnauthorizedException]. + * @param handleErrorBlock A lambda to execute if [block] fails with a non-panic, non-auth [FxaException]. + * @return object of type T, as defined by [block]. + */ +suspend fun <T> handleFxaExceptions( + logger: Logger, + operation: String, + block: suspend () -> T, + postHandleAuthErrorBlock: (e: FxaUnauthorizedException) -> T, + handleErrorBlock: (e: FxaException) -> T, +): T { + return try { + logger.info("Executing: $operation") + val res = block() + logger.info("Successfully executed: $operation") + res + } catch (e: FxaException) { + // We'd like to simply crash in case of certain errors (e.g. panics). + if (e.shouldPropagate()) { + throw e + } + when (e) { + is FxaUnauthorizedException -> { + logger.warn("Auth error while running: $operation") + GlobalAccountManager.authError(operation) + postHandleAuthErrorBlock(e) + } + else -> { + logger.error("Error while running: $operation", e) + handleErrorBlock(e) + } + } + } +} + +/** + * Helper method that handles [FxaException] and allows specifying a lazy default value via [default] + * block for use in case of errors. Execution is wrapped in log statements. + */ +suspend fun <T> handleFxaExceptions( + logger: Logger, + operation: String, + default: (error: FxaException) -> T, + block: suspend () -> T, +): T { + return handleFxaExceptions(logger, operation, block, { default(it) }, { default(it) }) +} + +/** + * Helper method that handles [FxaException] and returns a [Boolean] success flag as a result. + */ +suspend fun handleFxaExceptions(logger: Logger, operation: String, block: () -> Unit): Boolean { + return handleFxaExceptions( + logger, + operation, + { false }, + { + block() + true + }, + ) +} + +/** + * Simplified version of Kotlin's inline class version that can be used as a return value. + */ +internal sealed class Result<out T> { + data class Success<out T>(val value: T) : Result<T>() + object Failure : Result<Nothing>() +} + +/** + * A helper function which allows retrying a [block] of suspend code for a few times in case it fails. + * + * @param logger [Logger] that will be used to log retry attempts/results + * @param retryCount How many retry attempts are allowed + * @param block A suspend function to execute + * @return A [Result.Success] wrapping result of execution of [block] on (eventual) success, + * or [Result.Failure] otherwise. + */ +internal suspend fun <T> withRetries(logger: Logger, retryCount: Int, block: suspend () -> T): Result<T> { + var attempt = 0 + var res: T? = null + while (attempt < retryCount && (res == null || res == false)) { + attempt += 1 + logger.info("withRetries: attempt $attempt/$retryCount") + res = block() + } + return if (res == null || res == false) { + logger.warn("withRetries: all attempts failed") + Result.Failure + } else { + Result.Success(res) + } +} + +/** + * A helper function which allows retrying a [block] of suspend code for a few times in case it fails. + * Short-circuits execution if [block] returns [ServiceResult.AuthError] during any of its attempts. + * + * @param logger [Logger] that will be used to log retry attempts/results + * @param retryCount How many retry attempts are allowed + * @param block A suspend function to execute + * @return A [ServiceResult] representing result of [block] execution. + */ +internal suspend fun withServiceRetries( + logger: Logger, + retryCount: Int, + block: suspend () -> ServiceResult, +): ServiceResult { + var attempt = 0 + do { + attempt += 1 + logger.info("withServiceRetries: attempt $attempt/$retryCount") + when (val res = block()) { + ServiceResult.Ok, ServiceResult.AuthError -> return res + ServiceResult.OtherError -> {} + } + } while (attempt < retryCount) + + logger.warn("withServiceRetries: all attempts failed") + return ServiceResult.OtherError +} + +internal suspend fun String?.asAuthFlowUrl( + account: OAuthAccount, + scopes: Set<String>, + entrypoint: FxAEntryPoint, +): AuthFlowUrl? { + return if (this != null) { + account.beginPairingFlow(this, scopes, entrypoint) + } else { + account.beginOAuthFlow(scopes, entrypoint) + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/AppServicesStateMachineChecker.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/AppServicesStateMachineChecker.kt new file mode 100644 index 0000000000..e7ba7fdcdc --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/AppServicesStateMachineChecker.kt @@ -0,0 +1,224 @@ +/* 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.fxa.manager + +import mozilla.appservices.fxaclient.FxaEvent +import mozilla.appservices.fxaclient.FxaRustAuthState +import mozilla.appservices.fxaclient.FxaState +import mozilla.appservices.fxaclient.FxaStateCheckerEvent +import mozilla.appservices.fxaclient.FxaStateCheckerState +import mozilla.appservices.fxaclient.FxaStateMachineChecker +import mozilla.components.concept.sync.DeviceConfig +import mozilla.components.service.fxa.into +import mozilla.appservices.fxaclient.DeviceConfig as ASDeviceConfig + +/** + * Checks the new app-services state machine logic against the current android-components code + * + * This is a temporary measure to prep for migrating the android-components code to using the new + * app-services state machine. It performs a "dry-run" test of the code where we check the new logic + * against the current logic, without performing any side-effects. + * + * If one of the checks fails, then we report an error to Sentry. After that the calls become + * no-ops to avoid spamming sentry with errors from a single client. By checking the Sentry errors, + * we can find places where the application-services logic doesn't match the current + * android-components logic and fix the issue. + * + * Once we determine that the new application-services code is correct, let's switch the + * android-components code to using it (https://bugzilla.mozilla.org/show_bug.cgi?id=1867793) and + * delete all this code. + */ +object AppServicesStateMachineChecker { + /** + * The Rust state machine checker. This handles the actual checks and sentry reporting. + */ + private val rustChecker = FxaStateMachineChecker() + + /** + * Handle an event about to be processed + * + * Call this when processing an android-components event, after checking that it's valid. + */ + internal fun handleEvent(event: Event, deviceConfig: DeviceConfig, scopes: Set<String>) { + val convertedEvent = when (event) { + Event.Account.Start -> FxaEvent.Initialize( + ASDeviceConfig( + name = deviceConfig.name, + deviceType = deviceConfig.type.into(), + capabilities = ArrayList(deviceConfig.capabilities.map { it.into() }), + ), + ) + is Event.Account.BeginEmailFlow -> FxaEvent.BeginOAuthFlow(ArrayList(scopes), event.entrypoint.entryName) + is Event.Account.BeginPairingFlow -> { + // pairingUrl should always be non-null, if it is somehow null let's use a + // placeholder value that can be identified when checking in sentry + val pairingUrl = event.pairingUrl ?: "<null>" + FxaEvent.BeginPairingFlow(pairingUrl, ArrayList(scopes), event.entrypoint.entryName) + } + is Event.Account.AuthenticationError -> { + // There are basically 2 ways for this to happen: + // + // - Another component called `FxaAccountManager.encounteredAuthError()`. In this + // case, we should initiate the state transition by sending the state machine the + // `FxaEvent.CheckAuthorizationStatus` + // - `FxaAccountManager` sent it to itself, because there was an error when + // `internalStateSideEffects` called `finalizeDevice()`. In this case, we're + // already in the middle of a state transition and already sent the state machine + // the `EnsureCapabilitiesAuthError` event, so we should ignore it. + if (event.operation == "finalizeDevice") { + return + } else { + FxaEvent.CheckAuthorizationStatus + } + } + Event.Account.AccessTokenKeyError -> FxaEvent.CheckAuthorizationStatus + Event.Account.Logout -> FxaEvent.Disconnect + // This is the one ProgressEvent that's considered a "public event" in app-services + is Event.Progress.AuthData -> FxaEvent.CompleteOAuthFlow(event.authData.code, event.authData.state) + is Event.Progress.CancelAuth -> FxaEvent.CancelOAuthFlow + else -> return + } + rustChecker.handlePublicEvent(convertedEvent) + } + + /** + * Check a new account state + * + * Call this after transitioning to an android-components account state. + */ + internal fun checkAccountState(state: AccountState) { + val convertedState = when (state) { + AccountState.NotAuthenticated -> FxaState.Disconnected + is AccountState.Authenticating -> FxaState.Authenticating(state.oAuthUrl) + AccountState.Authenticated -> FxaState.Connected + AccountState.AuthenticationProblem -> FxaState.AuthIssues + } + rustChecker.checkPublicState(convertedState) + } + + /** + * General validation for new progress state being processed by the AC state machine. + * + * This handles all validation for most state transitions in a simple manner. The one transition + * it can't handle is completing oauth, which entails multiple FxA calls and can fail in multiple + * different ways. For that, the lower-level `checkInternalState` and `handleInternalEvent` are + * used. + */ + @Suppress("LongMethod") + internal fun validateProgressEvent(progressEvent: Event.Progress, via: Event, scopes: Set<String>) { + when (progressEvent) { + Event.Progress.AccountRestored -> { + AppServicesStateMachineChecker.checkInternalState(FxaStateCheckerState.GetAuthState) + AppServicesStateMachineChecker.handleInternalEvent( + FxaStateCheckerEvent.GetAuthStateSuccess(FxaRustAuthState.CONNECTED), + ) + } + Event.Progress.AccountNotFound -> { + AppServicesStateMachineChecker.checkInternalState(FxaStateCheckerState.GetAuthState) + AppServicesStateMachineChecker.handleInternalEvent( + FxaStateCheckerEvent.GetAuthStateSuccess(FxaRustAuthState.DISCONNECTED), + ) + } + is Event.Progress.StartedOAuthFlow -> { + when (via) { + is Event.Account.BeginEmailFlow -> { + AppServicesStateMachineChecker.checkInternalState( + FxaStateCheckerState.BeginOAuthFlow(ArrayList(scopes), via.entrypoint.entryName), + ) + AppServicesStateMachineChecker.handleInternalEvent( + FxaStateCheckerEvent.BeginOAuthFlowSuccess(progressEvent.oAuthUrl), + ) + } + is Event.Account.BeginPairingFlow -> { + AppServicesStateMachineChecker.checkInternalState( + FxaStateCheckerState.BeginPairingFlow( + via.pairingUrl!!, + ArrayList(scopes), + via.entrypoint.entryName, + ), + ) + AppServicesStateMachineChecker.handleInternalEvent( + FxaStateCheckerEvent.BeginPairingFlowSuccess(progressEvent.oAuthUrl), + ) + } + // This branch should never be taken, if it does we'll probably see a state + // check error down the line. + else -> Unit + } + } + Event.Progress.FailedToBeginAuth -> { + when (via) { + is Event.Account.BeginEmailFlow -> { + AppServicesStateMachineChecker.checkInternalState( + FxaStateCheckerState.BeginOAuthFlow(ArrayList(scopes), via.entrypoint.entryName), + ) + } + is Event.Account.BeginPairingFlow -> { + AppServicesStateMachineChecker.checkInternalState( + FxaStateCheckerState.BeginPairingFlow( + via.pairingUrl!!, + ArrayList(scopes), + via.entrypoint.entryName, + ), + ) + } + // This branch should never be taken, if it does we'll probably see a state + // check error down the line. + else -> Unit + } + AppServicesStateMachineChecker.handleInternalEvent(FxaStateCheckerEvent.CallError) + } + Event.Progress.LoggedOut -> { + AppServicesStateMachineChecker.checkInternalState( + FxaStateCheckerState.Disconnect, + ) + AppServicesStateMachineChecker.handleInternalEvent( + FxaStateCheckerEvent.DisconnectSuccess, + ) + } + Event.Progress.RecoveredFromAuthenticationProblem -> { + AppServicesStateMachineChecker.checkInternalState(FxaStateCheckerState.CheckAuthorizationStatus) + AppServicesStateMachineChecker.handleInternalEvent( + FxaStateCheckerEvent.CheckAuthorizationStatusSuccess(true), + ) + } + Event.Progress.FailedToRecoverFromAuthenticationProblem -> { + if (via is Event.Account.AuthenticationError && + via.errorCountWithinTheTimeWindow >= AUTH_CHECK_CIRCUIT_BREAKER_COUNT + ) { + // In this case, the state machine fails early and doesn't actualy make any + // calls + return + } + + AppServicesStateMachineChecker.checkInternalState(FxaStateCheckerState.CheckAuthorizationStatus) + AppServicesStateMachineChecker.handleInternalEvent( + FxaStateCheckerEvent.CheckAuthorizationStatusSuccess(false), + ) + } + else -> Unit + } + } + + /** + * Check an app-services internal state + * + * The app-services internal states correspond to internal firefox account method calls. Call + * this before making one of those calls. + */ + internal fun checkInternalState(state: FxaStateCheckerState) { + rustChecker.checkInternalState(state) + } + + /** + * Handle an app-services internal event + * + * The app-services internal states correspond the results of internal firefox account method + * calls. Call this before after making a call. + */ + internal fun handleInternalEvent(event: FxaStateCheckerEvent) { + rustChecker.handleInternalEvent(event) + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/FxaAccountManager.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/FxaAccountManager.kt new file mode 100644 index 0000000000..03a7d25635 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/FxaAccountManager.kt @@ -0,0 +1,938 @@ +/* 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.fxa.manager + +import android.content.Context +import androidx.annotation.GuardedBy +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.withContext +import mozilla.appservices.fxaclient.FxaStateCheckerEvent +import mozilla.appservices.fxaclient.FxaStateCheckerState +import mozilla.appservices.syncmanager.DeviceSettings +import mozilla.components.concept.base.crash.Breadcrumb +import mozilla.components.concept.base.crash.CrashReporting +import mozilla.components.concept.sync.AccountEventsObserver +import mozilla.components.concept.sync.AccountObserver +import mozilla.components.concept.sync.AuthFlowError +import mozilla.components.concept.sync.AuthType +import mozilla.components.concept.sync.DeviceConfig +import mozilla.components.concept.sync.FxAEntryPoint +import mozilla.components.concept.sync.OAuthAccount +import mozilla.components.concept.sync.Profile +import mozilla.components.concept.sync.ServiceResult +import mozilla.components.service.fxa.AccessTokenUnexpectedlyWithoutKey +import mozilla.components.service.fxa.AccountManagerException +import mozilla.components.service.fxa.AccountOnDisk +import mozilla.components.service.fxa.AccountStorage +import mozilla.components.service.fxa.FxaAuthData +import mozilla.components.service.fxa.FxaDeviceSettingsCache +import mozilla.components.service.fxa.FxaSyncScopedKeyMissingException +import mozilla.components.service.fxa.Result +import mozilla.components.service.fxa.SecureAbove22AccountStorage +import mozilla.components.service.fxa.ServerConfig +import mozilla.components.service.fxa.SharedPrefAccountStorage +import mozilla.components.service.fxa.StorageWrapper +import mozilla.components.service.fxa.SyncAuthInfoCache +import mozilla.components.service.fxa.SyncConfig +import mozilla.components.service.fxa.SyncEngine +import mozilla.components.service.fxa.asAuthFlowUrl +import mozilla.components.service.fxa.asSyncAuthInfo +import mozilla.components.service.fxa.emitSyncFailedFact +import mozilla.components.service.fxa.into +import mozilla.components.service.fxa.sync.SyncManager +import mozilla.components.service.fxa.sync.SyncReason +import mozilla.components.service.fxa.sync.SyncStatusObserver +import mozilla.components.service.fxa.sync.WorkManagerSyncManager +import mozilla.components.service.fxa.sync.clearSyncState +import mozilla.components.service.fxa.withRetries +import mozilla.components.service.fxa.withServiceRetries +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.base.observer.Observable +import mozilla.components.support.base.observer.ObserverRegistry +import mozilla.components.support.base.utils.NamedThreadFactory +import java.io.Closeable +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.CoroutineContext + +// Necessary to fetch a profile. +const val SCOPE_PROFILE = "profile" + +// Necessary to obtain sync keys. +const val SCOPE_SYNC = "https://identity.mozilla.com/apps/oldsync" + +// Necessary to obtain a sessionToken, which gives full access to the account. +const val SCOPE_SESSION = "https://identity.mozilla.com/tokens/session" + +// If we see more than AUTH_CHECK_CIRCUIT_BREAKER_COUNT checks, and each is less than +// AUTH_CHECK_CIRCUIT_BREAKER_RESET_MS since the last check, then we'll trigger a "circuit breaker". +const val AUTH_CHECK_CIRCUIT_BREAKER_RESET_MS = 1000L * 10 +const val AUTH_CHECK_CIRCUIT_BREAKER_COUNT = 10 +// This logic is in place to protect ourselves from endless auth recovery loops, while at the same +// time allowing for a few 401s to hit the state machine in a quick succession. +// For example, initializing the account state machine & syncing after letting our access tokens expire +// due to long period of inactivity will trigger a few 401s, and that shouldn't be a cause for concern. + +const val MAX_NETWORK_RETRIES = 3 + +/** + * An account manager which encapsulates various internal details of an account lifecycle and provides + * an observer interface along with a public API for interacting with an account. + * The internal state machine abstracts over state space as exposed by the fxaclient library, not + * the internal states experienced by lower-level representation of a Firefox Account; those are opaque to us. + * + * Class is 'open' to facilitate testing. + * + * @param context A [Context] instance that's used for internal messaging and interacting with local storage. + * @param serverConfig A [ServerConfig] used for account initialization. + * @param deviceConfig A description of the current device (name, type, capabilities). + * @param syncConfig Optional, initial sync behaviour configuration. Sync will be disabled if this is `null`. + * @param applicationScopes A set of scopes which will be requested during account authentication. + */ +@Suppress("TooManyFunctions", "LargeClass") +open class FxaAccountManager( + private val context: Context, + @get:VisibleForTesting val serverConfig: ServerConfig, + private val deviceConfig: DeviceConfig, + private val syncConfig: SyncConfig?, + private val applicationScopes: Set<String> = emptySet(), + private val crashReporter: CrashReporting? = null, + // We want a single-threaded execution model for our account-related "actions" (state machine side-effects). + // That is, we want to ensure a sequential execution flow, but on a background thread. + private val coroutineContext: CoroutineContext = Executors.newSingleThreadExecutor( + NamedThreadFactory("FxaAccountManager"), + ).asCoroutineDispatcher() + SupervisorJob(), +) : Closeable, Observable<AccountObserver> by ObserverRegistry() { + private val logger = Logger("FirefoxAccountStateMachine") + + @Volatile + private var latestAuthState: String? = null + + // Used to detect multiple auth recovery attempts at once + // (https://bugzilla.mozilla.org/show_bug.cgi?id=1864994) + private var authRecoveryScheduled = AtomicBoolean(false) + private fun checkForMultipleRequiveryCalls() { + val alreadyScheduled = authRecoveryScheduled.getAndSet(true) + if (alreadyScheduled) { + crashReporter?.recordCrashBreadcrumb(Breadcrumb("multiple fxa recoveries scheduled at once")) + } + } + private fun finishedAuthRecovery() { + authRecoveryScheduled.set(false) + } + + init { + GlobalAccountManager.setInstance(this) + } + + private val accountOnDisk by lazy { getStorageWrapper().account() } + private val account by lazy { accountOnDisk.account() } + + // Note on threading: we use a single-threaded executor, so there's no concurrent access possible. + // However, that executor doesn't guarantee that it'll always use the same thread, and so vars + // are marked as volatile for across-thread visibility. Similarly, event queue uses a concurrent + // list, although that's probably an overkill. + @Volatile private var profile: Profile? = null + + // We'd like to persist this state, so that we can short-circuit transition to AuthenticationProblem on + // initialization, instead of triggering the full state machine knowing in advance we'll hit auth problems. + // See https://github.com/mozilla-mobile/android-components/issues/5102 + @Volatile private var state: State = State.Idle(AccountState.NotAuthenticated) + + @Volatile private var isAccountManagerReady: Boolean = false + private val eventQueue = ConcurrentLinkedQueue<Event>() + + @VisibleForTesting + val accountEventObserverRegistry = ObserverRegistry<AccountEventsObserver>() + + @VisibleForTesting + open val syncStatusObserverRegistry = ObserverRegistry<SyncStatusObserver>() + + // We always obtain a "profile" scope, as that's assumed to be needed for any application integration. + // We obtain a sync scope only if this was requested by the application via SyncConfig. + // Additionally, we obtain any scopes that the application requested explicitly. + private val scopes: Set<String> + get() = if (syncConfig != null) { + setOf(SCOPE_PROFILE, SCOPE_SYNC) + } else { + setOf(SCOPE_PROFILE) + }.plus(applicationScopes) + + // Internal backing field for the syncManager. This will be `null` if passed in SyncConfig (either + // via the constructor, or via [setSyncConfig]) is also `null` - that is, sync will be disabled. + // Note that trying to perform a sync while account isn't authenticated will not succeed. + @GuardedBy("this") + private var syncManager: SyncManager? = null + + init { + syncConfig?.let { + // Initialize sync manager with the passed-in config. + if (syncConfig.supportedEngines.isEmpty()) { + throw IllegalArgumentException("Set of supported engines can't be empty") + } + + syncManager = createSyncManager(syncConfig).also { + // Observe account state changes. + this.register(AccountsToSyncIntegration(it)) + + // Observe sync status changes. + it.registerSyncStatusObserver(SyncToAccountsIntegration(this)) + } + } + + if (syncManager == null) { + logger.info("Sync is disabled") + } else { + logger.info("Sync is enabled") + } + } + + /** + * @return A list of currently supported [SyncEngine]s. `null` if sync isn't configured. + */ + fun supportedSyncEngines(): Set<SyncEngine>? { + // Notes on why this exists: + // Parts of the system that make up an "fxa + sync" experience need to know which engines + // are supported by an application. For example, FxA web content UI may present a "choose what + // to sync" dialog during account sign-up, and application needs to be able to configure that + // dialog. A list of supported engines comes to us from the application via passed-in SyncConfig. + // Naturally, we could let the application configure any other part of the system that needs + // to have access to supported engines. From the implementor's point of view, this is an extra + // hurdle - instead of configuring only the account manager, they need to configure additional + // classes. Additionally, we currently allow updating sync config "in-flight", not just at + // the time of initialization. Providing an API for accessing currently configured engines + // makes re-configuring SyncConfig less error-prone, as only one class needs to be told of the + // new config. + // Merits of allowing applications to re-configure SyncConfig after initialization are under + // question, however: currently, we do not use that capability. + return syncConfig?.supportedEngines + } + + /** + * Request an immediate synchronization, as configured according to [syncConfig]. + * + * @param reason A [SyncReason] indicating why this sync is being requested. + * @param debounce Boolean flag indicating if this sync may be debounced (in case another sync executed recently). + * @param customEngineSubset A subset of supported engines to sync. Defaults to all supported engines. + */ + suspend fun syncNow( + reason: SyncReason, + debounce: Boolean = false, + customEngineSubset: List<SyncEngine> = listOf(), + ) = withContext(coroutineContext) { + check( + customEngineSubset.isEmpty() || + syncConfig?.supportedEngines?.containsAll(customEngineSubset) == true, + ) { + "Custom engines for sync must be a subset of supported engines." + } + when (val s = state) { + // Can't sync while we're still doing stuff. + is State.Active -> Unit + is State.Idle -> when (s.accountState) { + // All good, request a sync. + AccountState.Authenticated -> { + // Make sure auth cache is populated before we try to sync. + try { + maybeUpdateSyncAuthInfoCache() + } catch (e: AccessTokenUnexpectedlyWithoutKey) { + crashReporter?.submitCaughtException( + AccountManagerException.MissingKeyFromSyncScopedAccessToken("syncNow"), + ) + processQueue(Event.Account.AccessTokenKeyError) + // No point in trying to sync now. + return@withContext + } + + // Access to syncManager is guarded by `this`. + synchronized(this@FxaAccountManager) { + checkNotNull(syncManager == null) { + "Sync is not configured. Construct this class with a 'syncConfig' or use 'setSyncConfig'" + } + syncManager?.now(reason, debounce, customEngineSubset) + } + } + else -> logger.info("Ignoring syncNow request, not in the right state: $s") + } + } + } + + /** + * Indicates if sync is currently running. + */ + fun isSyncActive() = syncManager?.isSyncActive() ?: false + + /** + * Call this after registering your observers, and before interacting with this class. + */ + suspend fun start() = withContext(coroutineContext) { + processQueue(Event.Account.Start) + + if (!isAccountManagerReady) { + notifyObservers { onReady(authenticatedAccount()) } + isAccountManagerReady = true + } + } + + /** + * Main point for interaction with an [OAuthAccount] instance. + * @return [OAuthAccount] if we're in an authenticated state, null otherwise. Returned [OAuthAccount] + * may need to be re-authenticated; consumers are expected to check [accountNeedsReauth]. + */ + fun authenticatedAccount(): OAuthAccount? = when (val s = state) { + is State.Idle -> when (s.accountState) { + AccountState.Authenticated, + AccountState.AuthenticationProblem, + -> account + else -> null + } + else -> null + } + + /** + * Indicates if account needs to be re-authenticated via [beginAuthentication]. + * Most common reason for an account to need re-authentication is a password change. + * + * TODO this may return a false-positive, if we're currently going through a recovery flow. + * Prefer to be notified of auth problems via [AccountObserver], which is reliable. + * + * @return A boolean flag indicating if account needs to be re-authenticated. + */ + fun accountNeedsReauth() = when (val s = state) { + is State.Idle -> when (s.accountState) { + AccountState.AuthenticationProblem -> true + else -> false + } + else -> false + } + + /** + * Returns a [Profile] for an account, attempting to fetch it if necessary. + * @return [Profile] if one is available and account is an authenticated state. + */ + fun accountProfile(): Profile? = when (val s = state) { + is State.Idle -> when (s.accountState) { + AccountState.Authenticated, + AccountState.AuthenticationProblem, + -> profile + else -> null + } + else -> null + } + + @VisibleForTesting + internal suspend fun refreshProfile(ignoreCache: Boolean): Profile? { + return authenticatedAccount()?.getProfile(ignoreCache = ignoreCache)?.let { newProfile -> + profile = newProfile + notifyObservers { + onProfileUpdated(newProfile) + } + profile + } + } + + /** + * Begins an authentication process. Should be finalized by calling [finishAuthentication] once + * user successfully goes through the authentication at the returned url. + * @param pairingUrl Optional pairing URL in case a pairing flow is being initiated. + * @param entrypoint an enum representing the feature entrypoint requesting the URL. + * the entrypoint is used in telemetry. + * @return An authentication url which is to be presented to the user. + */ + suspend fun beginAuthentication( + pairingUrl: String? = null, + entrypoint: FxAEntryPoint, + ): String? = withContext(coroutineContext) { + // It's possible that at this point authentication is considered to be "in-progress". + // For example, if user started authentication flow, but cancelled it (closing a custom tab) + // without finishing. + // In a clean scenario (no prior auth attempts), this event will be ignored by the state machine. + processQueue(Event.Progress.CancelAuth) + + val event = if (pairingUrl != null) { + Event.Account.BeginPairingFlow(pairingUrl, entrypoint) + } else { + Event.Account.BeginEmailFlow(entrypoint) + } + + // Process the event, then use the new state to check the result of the operation + processQueue(event) + when (val state = state) { + is State.Idle -> (state.accountState as? AccountState.Authenticating)?.oAuthUrl + else -> null + }.also { result -> + if (result == null) { + logger.warn("beginAuthentication: error processing next state ($state)") + } + } + } + + /** + * Finalize authentication that was started via [beginAuthentication]. + * + * If authentication wasn't started via this manager we won't accept this authentication attempt, + * returning `false`. This may happen if [WebChannelFeature] is enabled, and user is manually + * logging into accounts.firefox.com in a regular tab. + * + * Guiding principle behind this is that logging into accounts.firefox.com should not affect + * logged-in state of the browser itself, even though the two may have an established communication + * channel via [WebChannelFeature]. + */ + suspend fun finishAuthentication(authData: FxaAuthData) = withContext(coroutineContext) { + when { + latestAuthState == null -> { + logger.warn("Trying to finish authentication that was never started.") + false + } + authData.state != latestAuthState -> { + logger.warn("Trying to finish authentication for an invalid auth state; ignoring.") + false + } + authData.state == latestAuthState -> { + authData.declinedEngines?.let { persistDeclinedEngines(it) } + processQueue(Event.Progress.AuthData(authData)) + true + } + else -> throw IllegalStateException("Unexpected finishAuthentication state") + } + } + + /** + * Logout of the account, if currently logged-in. + */ + suspend fun logout() = withContext(coroutineContext) { processQueue(Event.Account.Logout) } + + /** + * Register a [AccountEventsObserver] to monitor events relevant to an account/device. + */ + fun registerForAccountEvents(observer: AccountEventsObserver, owner: LifecycleOwner, autoPause: Boolean) { + accountEventObserverRegistry.register(observer, owner, autoPause) + } + + /** + * Register a [SyncStatusObserver] to monitor sync activity performed by this manager. + */ + fun registerForSyncEvents(observer: SyncStatusObserver, owner: LifecycleOwner, autoPause: Boolean) { + syncStatusObserverRegistry.register(observer, owner, autoPause) + } + + /** + * Unregister a [SyncStatusObserver] from being informed about "sync lifecycle" events. + * The method is safe to call even if the provided observer was not registered before. + */ + fun unregisterForSyncEvents(observer: SyncStatusObserver) { + syncStatusObserverRegistry.unregister(observer) + } + + override fun close() { + GlobalAccountManager.close() + coroutineContext.cancel() + account.close() + } + + internal suspend fun encounteredAuthError( + operation: String, + errorCountWithinTheTimeWindow: Int = 1, + ) { + checkForMultipleRequiveryCalls() + return withContext(coroutineContext) { + processQueue( + Event.Account.AuthenticationError(operation, errorCountWithinTheTimeWindow), + ) + } + } + + /** + * Pumps the state machine until all events are processed and their side-effects resolve. + */ + private suspend fun processQueue(event: Event) { + crashReporter?.recordCrashBreadcrumb( + Breadcrumb("fxa-state-machine-checker: a-c transition started (event: ${event.breadcrumbDisplay()})"), + ) + eventQueue.add(event) + do { + val toProcess: Event = eventQueue.poll()!! + val transitionInto = state.next(toProcess) + + crashReporter?.recordCrashBreadcrumb( + Breadcrumb( + "fxa-state-machine-checker: a-c transition " + + "(event: ${toProcess.breadcrumbDisplay()}, into: ${transitionInto?.breadcrumbDisplay()})", + ), + ) + + if (transitionInto == null) { + logger.warn("Got invalid event '$toProcess' for state $state.") + continue + } + + AppServicesStateMachineChecker.handleEvent(toProcess, deviceConfig, scopes) + if (transitionInto is State.Idle) { + AppServicesStateMachineChecker.checkAccountState(transitionInto.accountState) + } + + logger.info("Processing event '$toProcess' for state $state. Next state is $transitionInto") + + state = transitionInto + + stateActions(state, toProcess)?.let { successiveEvent -> + logger.info("Ran '$toProcess' side-effects for state $state, got successive event $successiveEvent") + if (successiveEvent is Event.Progress) { + // Note: stateActions should only return progress events, so this captures all + // possibilities. + AppServicesStateMachineChecker.validateProgressEvent(successiveEvent, toProcess, scopes) + } + eventQueue.add(successiveEvent) + } + } while (!eventQueue.isEmpty()) + } + + /** + * Side-effects of entering [AccountState] type states + * + * Upon entering these states, observers are typically notified. The sole exception occurs + * during the completion of authentication, where it is necessary to populate the + * SyncAuthInfoCache for the background synchronization worker. + * + * @throws [AccountManagerException.AuthenticationSideEffectsFailed] if there was a failure to + * run the side effects for a newly authenticated account. + */ + private suspend fun accountStateSideEffects( + forState: State.Idle, + via: Event, + ): Unit = when (forState.accountState) { + AccountState.NotAuthenticated -> when (via) { + Event.Progress.LoggedOut -> { + resetAccount() + notifyObservers { onLoggedOut() } + } + Event.Progress.FailedToBeginAuth -> { + resetAccount() + notifyObservers { onFlowError(AuthFlowError.FailedToBeginAuth) } + } + Event.Progress.FailedToCompleteAuth -> { + resetAccount() + notifyObservers { onFlowError(AuthFlowError.FailedToCompleteAuth) } + } + Event.Progress.FailedToRecoverFromAuthenticationProblem -> { + finishedAuthRecovery() + } + else -> Unit + } + AccountState.Authenticated -> when (via) { + is Event.Progress.CompletedAuthentication -> { + val operation = when (via.authType) { + AuthType.Existing -> "CompletingAuthentication:accountRestored" + else -> "CompletingAuthentication:AuthData" + } + if (authenticationSideEffects(operation)) { + notifyObservers { onAuthenticated(account, via.authType) } + refreshProfile(ignoreCache = false) + Unit + } else { + throw AccountManagerException.AuthenticationSideEffectsFailed() + } + } + Event.Progress.RecoveredFromAuthenticationProblem -> { + finishedAuthRecovery() + // Clear our access token cache; it'll be re-populated as part of the + // regular state machine flow. + SyncAuthInfoCache(context).clear() + // Should we also call authenticationSideEffects here? + // (https://bugzilla.mozilla.org/show_bug.cgi?id=1865086) + notifyObservers { onAuthenticated(account, AuthType.Recovered) } + refreshProfile(ignoreCache = true) + Unit + } + else -> Unit + } + AccountState.AuthenticationProblem -> { + SyncAuthInfoCache(context).clear() + notifyObservers { onAuthenticationProblems() } + } + else -> Unit + } + + /** + * Side-effects of entering [ProgressState] states. These side-effects are actions we need to take + * to perform a state transition. For example, we wipe local state while entering a [ProgressState.LoggingOut]. + * + * @return An optional follow-up [Event] that we'd like state machine to process after entering [forState] + * and processing its side-effects. + */ + @Suppress("NestedBlockDepth", "LongMethod") + private suspend fun internalStateSideEffects( + forState: State.Active, + via: Event, + ): Event? = when (forState.progressState) { + ProgressState.Initializing -> { + when (accountOnDisk) { + is AccountOnDisk.New -> Event.Progress.AccountNotFound + is AccountOnDisk.Restored -> { + Event.Progress.AccountRestored + } + } + } + ProgressState.LoggingOut -> { + Event.Progress.LoggedOut + } + ProgressState.BeginningAuthentication -> when (via) { + is Event.Account.BeginPairingFlow, is Event.Account.BeginEmailFlow -> { + val pairingUrl = if (via is Event.Account.BeginPairingFlow) { + via.pairingUrl + } else { + null + } + val entrypoint = if (via is Event.Account.BeginEmailFlow) { + via.entrypoint + } else if (via is Event.Account.BeginPairingFlow) { + via.entrypoint + } else { + // This should be impossible, both `BeginPairingFlow` and `BeginEmailFlow` + // have a required `entrypoint` and we are matching against only instances + // of those data classes. + throw IllegalStateException("BeginningAuthentication with a flow that is neither email nor pairing") + } + val result = withRetries(logger, MAX_NETWORK_RETRIES) { + pairingUrl.asAuthFlowUrl(account, scopes, entrypoint = entrypoint) + } + when (result) { + is Result.Success -> { + latestAuthState = result.value!!.state + Event.Progress.StartedOAuthFlow(result.value.url) + } + Result.Failure -> { + Event.Progress.FailedToBeginAuth + } + } + } + else -> null + } + ProgressState.CompletingAuthentication -> when (via) { + Event.Progress.AccountRestored -> { + val authType = AuthType.Existing + when (withServiceRetries(logger, MAX_NETWORK_RETRIES) { finalizeDevice(authType) }) { + ServiceResult.Ok -> { + Event.Progress.CompletedAuthentication(authType) + } + ServiceResult.AuthError -> { + checkForMultipleRequiveryCalls() + Event.Account.AuthenticationError("finalizeDevice") + } + ServiceResult.OtherError -> { + AppServicesStateMachineChecker.handleInternalEvent(FxaStateCheckerEvent.CallError) + Event.Progress.FailedToCompleteAuthRestore + } + } + } + is Event.Progress.AuthData -> { + val completeAuth = suspend { + AppServicesStateMachineChecker.checkInternalState( + FxaStateCheckerState.CompleteOAuthFlow(via.authData.code, via.authData.state), + ) + withRetries(logger, MAX_NETWORK_RETRIES) { + account.completeOAuthFlow(via.authData.code, via.authData.state) + }.also { + if (it is Result.Failure) { + AppServicesStateMachineChecker.handleInternalEvent(FxaStateCheckerEvent.CallError) + } else { + AppServicesStateMachineChecker.handleInternalEvent( + FxaStateCheckerEvent.CompleteOAuthFlowSuccess, + ) + } + } + } + val finalize = suspend { + // Note: finalizeDevice state checking happens in the DeviceConstellation.kt + withServiceRetries(logger, MAX_NETWORK_RETRIES) { finalizeDevice(via.authData.authType) }.also { + if (it is ServiceResult.OtherError) { + AppServicesStateMachineChecker.handleInternalEvent(FxaStateCheckerEvent.CallError) + } + } + } + // If we can't 'complete', we won't run 'finalize' due to short-circuiting. + if (completeAuth() is Result.Failure || finalize() !is ServiceResult.Ok) { + Event.Progress.FailedToCompleteAuth + } else { + Event.Progress.CompletedAuthentication(via.authData.authType) + } + } + else -> null + } + ProgressState.RecoveringFromAuthProblem -> { + via as Event.Account.AuthenticationError + // Somewhere in the system, we've just hit an authentication problem. + // There are two main causes: + // 1) an access token we've obtain from fxalib via 'getAccessToken' expired + // 2) password was changed, or device was revoked + // We can recover from (1) and test if we're in (2) by asking the fxalib + // to give us a new access token. If it succeeds, then we can go back to whatever + // state we were in before. Future operations that involve access tokens should + // succeed. + // If we fail with a 401, then we know we have a legitimate authentication problem. + logger.info("Hit auth problem. Trying to recover.") + + // Ensure we clear any auth-relevant internal state, such as access tokens. + account.authErrorDetected() + + // Circuit-breaker logic to protect ourselves from getting into endless authorization + // check loops. If we determine that application is trying to check auth status too + // frequently, drive the state machine into an unauthorized state. + if (via.errorCountWithinTheTimeWindow >= AUTH_CHECK_CIRCUIT_BREAKER_COUNT) { + crashReporter?.submitCaughtException( + AccountManagerException.AuthRecoveryCircuitBreakerException(via.operation), + ) + logger.warn("Unable to recover from an auth problem, triggered a circuit breaker.") + + Event.Progress.FailedToRecoverFromAuthenticationProblem + } else { + // Since we haven't hit the circuit-breaker above, perform an authorization check. + // We request an access token for a "profile" scope since that's the only + // scope we're guaranteed to have at this point. That is, we don't rely on + // passed-in application-specific scopes. + when (account.checkAuthorizationStatus(SCOPE_PROFILE)) { + true -> { + logger.info("Able to recover from an auth problem.") + + // And now we can go back to our regular programming. + Event.Progress.RecoveredFromAuthenticationProblem + } + null, false -> { + // We are either certainly in the scenario (2), or were unable to determine + // our connectivity state. Let's assume we need to re-authenticate. + // This uncertainty about real state means that, hopefully rarely, + // we will disconnect users that hit transient network errors during + // an authorization check. + // See https://github.com/mozilla-mobile/android-components/issues/3347 + logger.info("Unable to recover from an auth problem, notifying observers.") + + Event.Progress.FailedToRecoverFromAuthenticationProblem + } + } + } + } + } + + /** + * Side-effects matrix. Defines non-pure operations that must take place for state+event combinations. + */ + @Suppress("ComplexMethod", "ReturnCount", "ThrowsCount", "NestedBlockDepth", "LongMethod") + private suspend fun stateActions(forState: State, via: Event): Event? = when (forState) { + // We're about to enter a new state ('forState') via some event ('via'). + // States will have certain side-effects associated with different event transitions. + // In other words, the same state may have different side-effects depending on the event + // which caused a transition. + // For example, a "NotAuthenticated" state may be entered after a logout, and its side-effects + // will include clean-up and re-initialization of an account. Alternatively, it may be entered + // after we've checked local disk, and didn't find a persisted authenticated account. + is State.Idle -> try { + accountStateSideEffects(forState, via) + null + } catch (_: AccountManagerException.AuthenticationSideEffectsFailed) { + Event.Account.Logout + } + is State.Active -> internalStateSideEffects(forState, via) + } + + private suspend fun resetAccount() { + // Clean up internal account state and destroy the current FxA device record (if one exists). + // This can fail (network issues, auth problems, etc), but nothing we can do at this point. + account.disconnect() + + // Clean up resources. + profile = null + // Delete persisted state. + getAccountStorage().clear() + // Even though we might not have Sync enabled, clear out sync-related storage + // layers as well; if they're already empty (unused), nothing bad will happen + // and extra overhead is quite small. + SyncAuthInfoCache(context).clear() + SyncEnginesStorage(context).clear() + clearSyncState(context) + } + + private suspend fun maybeUpdateSyncAuthInfoCache() { + // Update cached sync auth info only if we have a syncConfig (e.g. sync is enabled)... + if (syncConfig == null) { + return + } + + // .. and our cache is stale. + val cache = SyncAuthInfoCache(context) + if (!cache.expired()) { + return + } + + val accessToken = try { + account.getAccessToken(SCOPE_SYNC) + } catch (e: FxaSyncScopedKeyMissingException) { + // We received an access token, but no sync key which means we can't really use the + // connected FxA account. Throw an exception so that the account transitions to the + // `AuthenticationProblem` state. Things should be fixed when the user re-logs in. + // + // This used to be thrown when the android-components code noticed the issue in + // `asSyncAuthInfo()`. However, the application-services code now also checks for this + // and throws its own error. To keep the flow above this the same, we catch the + // app-services exception and throw the android-components one. + // + // Eventually, we should remove AccessTokenUnexpectedlyWithoutKey and have the higher + // functions catch `FxaSyncScopedKeyMissingException` directly + // (https://bugzilla.mozilla.org/show_bug.cgi?id=1869862) + throw AccessTokenUnexpectedlyWithoutKey() + } + val tokenServerUrl = if (accessToken != null) { + // Only try to get the endpoint if we have an access token. + account.getTokenServerEndpointURL() + } else { + null + } + + if (accessToken != null && tokenServerUrl != null) { + SyncAuthInfoCache(context).setToCache(accessToken.asSyncAuthInfo(tokenServerUrl)) + } else { + // At this point, SyncAuthInfoCache may be entirely empty. In this case, we won't be + // able to sync via the background worker. We will attempt to populate SyncAuthInfoCache + // again in `syncNow` (in response to a direct user action) and after application restarts. + logger.warn("Couldn't populate SyncAuthInfoCache. Sync may not work.") + logger.warn("Is null? - accessToken: ${accessToken == null}, tokenServerUrl: ${tokenServerUrl == null}") + } + } + + private fun persistDeclinedEngines(declinedEngines: Set<SyncEngine>) { + // Sync may not be configured at all (e.g. won't run), but if we received a + // list of declined engines, that indicates user intent, so we preserve it + // within SyncEnginesStorage regardless. If sync becomes enabled later on, + // we will be respecting user choice around what to sync. + val enginesStorage = SyncEnginesStorage(context) + declinedEngines.forEach { declinedEngine -> + enginesStorage.setStatus(declinedEngine, false) + } + + // Enable all engines that were not explicitly disabled. Only do this in + // the presence of a "declinedEngines" list, since that indicates user + // intent. Absence of that list means that user was not presented with a + // choice during authentication, and so we can't assume an enabled state + // for any of the engines. + syncConfig?.supportedEngines?.forEach { supportedEngine -> + if (!declinedEngines.contains(supportedEngine)) { + enginesStorage.setStatus(supportedEngine, true) + } + } + } + + private suspend fun finalizeDevice(authType: AuthType) = account.deviceConstellation().finalizeDevice( + authType, + deviceConfig, + ) + + /** + * Populates caches necessary for the sync worker (sync auth info and FxA device). + * @return 'true' on success, 'false' on failure, indicating that sync won't work. + */ + private suspend fun authenticationSideEffects(operation: String): Boolean { + // Make sure our SyncAuthInfo cache is hot, background sync worker needs it to function. + try { + maybeUpdateSyncAuthInfoCache() + } catch (e: AccessTokenUnexpectedlyWithoutKey) { + crashReporter?.submitCaughtException( + AccountManagerException.MissingKeyFromSyncScopedAccessToken(operation), + ) + // Since we don't know what's causing a missing key for the SCOPE_SYNC access tokens, we + // do not attempt to recover here. If this is a persistent state for an account, a recovery + // will just enter into a loop that our circuit breaker logic is unlikely to catch, due + // to recovery attempts likely being made on startup. + // See https://github.com/mozilla-mobile/android-components/issues/8527 + return false + } + + // Sync workers also need to know about the current FxA device. + FxaDeviceSettingsCache(context).setToCache( + DeviceSettings( + fxaDeviceId = account.getCurrentDeviceId()!!, + name = deviceConfig.name, + kind = deviceConfig.type.into(), + ), + ) + return true + } + + /** + * Exists strictly for testing purposes, allowing tests to specify their own implementation of [OAuthAccount]. + */ + @VisibleForTesting + open fun getStorageWrapper(): StorageWrapper { + return StorageWrapper(this, accountEventObserverRegistry, serverConfig, crashReporter) + } + + @VisibleForTesting + internal open fun createSyncManager(config: SyncConfig): SyncManager { + return WorkManagerSyncManager(context, config) + } + + internal open fun getAccountStorage(): AccountStorage { + return if (deviceConfig.secureStateAtRest) { + SecureAbove22AccountStorage(context, crashReporter) + } else { + SharedPrefAccountStorage(context, crashReporter) + } + } + + /** + * Account status events flowing into the sync manager. + */ + @VisibleForTesting + internal class AccountsToSyncIntegration( + private val syncManager: SyncManager, + ) : AccountObserver { + override fun onLoggedOut() { + syncManager.stop() + } + + override fun onAuthenticated(account: OAuthAccount, authType: AuthType) { + val reason = when (authType) { + is AuthType.OtherExternal, AuthType.Signin, AuthType.Signup, AuthType.MigratedReuse, + AuthType.MigratedCopy, AuthType.Pairing, + -> SyncReason.FirstSync + AuthType.Existing, AuthType.Recovered -> SyncReason.Startup + } + syncManager.start() + syncManager.now(reason) + } + + override fun onProfileUpdated(profile: Profile) { + // Sync currently doesn't care about the FxA profile. + // In the future, we might kick-off an immediate sync here. + } + + override fun onAuthenticationProblems() { + emitSyncFailedFact() + syncManager.stop() + } + } + + /** + * Sync status changes flowing into account manager. + */ + private class SyncToAccountsIntegration( + private val accountManager: FxaAccountManager, + ) : SyncStatusObserver { + override fun onStarted() { + accountManager.syncStatusObserverRegistry.notifyObservers { onStarted() } + } + + override fun onIdle() { + accountManager.syncStatusObserverRegistry.notifyObservers { onIdle() } + } + + override fun onError(error: Exception?) { + accountManager.syncStatusObserverRegistry.notifyObservers { onError(error) } + } + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/GlobalAccountManager.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/GlobalAccountManager.kt new file mode 100644 index 0000000000..837ea888cb --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/GlobalAccountManager.kt @@ -0,0 +1,79 @@ +/* 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.fxa.manager + +import androidx.annotation.VisibleForTesting +import java.lang.ref.WeakReference +import java.util.concurrent.TimeUnit + +/** + * A singleton which exposes an instance of [FxaAccountManager] for internal consumption. + * Populated during initialization of [FxaAccountManager]. + * This exists to allow various internal parts without a direct reference to an instance of + * [FxaAccountManager] to notify it of encountered auth errors via [authError]. + */ +internal object GlobalAccountManager { + private var instance: WeakReference<FxaAccountManager>? = null + private var lastAuthErrorCheckPoint: Long = 0L + private var authErrorCountWithinWindow: Int = 0 + + internal interface Clock { + fun getTimeCheckPoint(): Long + } + + private val systemClock = object : Clock { + override fun getTimeCheckPoint(): Long { + // nanoTime to decouple from wall-time. + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime()) + } + } + + internal fun setInstance(am: FxaAccountManager) { + instance = WeakReference(am) + lastAuthErrorCheckPoint = 0 + authErrorCountWithinWindow = 0 + } + + internal fun close() { + instance = null + } + + internal suspend fun authError( + operation: String, + forSync: Boolean = false, + @VisibleForTesting clock: Clock = systemClock, + ) { + val authErrorCheckPoint: Long = clock.getTimeCheckPoint() + + val timeSinceLastAuthErrorMs: Long? = if (lastAuthErrorCheckPoint == 0L) { + null + } else { + authErrorCheckPoint - lastAuthErrorCheckPoint + } + lastAuthErrorCheckPoint = authErrorCheckPoint + + if (timeSinceLastAuthErrorMs == null) { + // First error, start our count. + authErrorCountWithinWindow = 1 + } else if (timeSinceLastAuthErrorMs <= AUTH_CHECK_CIRCUIT_BREAKER_RESET_MS) { + // In general, skip additional checks inside the `AUTH_CHECK_CIRCUIT_BREAKER_RESET_MS`. + // This avoids queueing up multiple auth recovery checks when multiple operations run at + // the same time and result in auth errors. + // + // The one exception is sync, which retries on a successful recovery. In that case we + // should to run through the recovery process to make sure the sync happens. + if (!forSync) { + return + } + // Error within the reset time window, increment the count. + authErrorCountWithinWindow += 1 + } else { + // Error outside the reset window, reset the count. + authErrorCountWithinWindow = 1 + } + + instance?.get()?.encounteredAuthError(operation, authErrorCountWithinWindow) + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/State.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/State.kt new file mode 100644 index 0000000000..111715703a --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/State.kt @@ -0,0 +1,216 @@ +/* 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.fxa.manager + +import mozilla.components.concept.sync.AuthType +import mozilla.components.concept.sync.FxAEntryPoint +import mozilla.components.service.fxa.FxaAuthData + +/** + * This file is the "heart" of the accounts system. It's a finite state machine. + * Described below are all of the possible states, transitions between them and events which can + * trigger these transitions. + * + * There are two types of states - [State.Idle] and [State.Active]. Idle states are modeled by + * [AccountState], while active states are modeled by [ProgressState]. + * + * Correspondingly, there are also two types of events - [Event.Account] and [Event.Progress]. + * [Event.Account] events will be "understood" by states of type [AccountState]. + * [Event.Progress] events will be "understood" by states of type [ProgressState]. + * The word "understood" means "may trigger a state transition". + * + * [Event.Account] events are also referred to as "external events", since they are sent either directly + * by the user, or by some event of external origin (e.g. hitting an auth problem while communicating + * with a server). + * + * Similarly, [Event.Progress] events are referred to as "internal events", since they are sent by + * the internal processes within the state machine itself, e.g. as a result of some action being taken + * as a side-effect of a state transition. + * + * States of type [AccountState] are "stable" states (or, idle). After reaching one of these states, + * state machine will remain there unless an [Event.Account] is received. An example of a stable state + * is LoggedOut. No transitions will take place unless user tries to sign-in, or system tries to restore + * an account from disk (e.g. during startup). + * + * States of type [ProgressState] are "transition" state (or, active). These states represent certain processes + * that take place - for example, an in-progress authentication, or an account recovery. + * Once these processes are completed (either succeeding or failing), an [Event.Progress] is expected + * to be received, triggering a state transition. + * + * Most states have "side-effects" - actions that happen during a transition into the state. + * These side-effects are described in [FxaAccountManager]. For example, a side-effect of + * [ProgressState.BeginningAuthentication] may be sending a request to an auth server to initialize an OAuth flow. + * + * Side-effects of [AccountState] states are simply about notifying external observers that a certain + * stable account state was reached. + * + * Side-effects of [ProgressState] states are various actions we need to take to execute the transition, + * e.g. talking to servers, serializing some state to disk, cleaning up, etc. + * + * State transitions are described by a transition matrix, which is described in [State.next]. + */ + +internal sealed class AccountState { + object Authenticated : AccountState() + data class Authenticating(val oAuthUrl: String) : AccountState() + object AuthenticationProblem : AccountState() + object NotAuthenticated : AccountState() +} + +internal enum class ProgressState { + Initializing, + BeginningAuthentication, + CompletingAuthentication, + RecoveringFromAuthProblem, + LoggingOut, +} + +internal sealed class Event { + internal sealed class Account : Event() { + internal object Start : Account() + data class BeginEmailFlow(val entrypoint: FxAEntryPoint) : Account() + data class BeginPairingFlow(val pairingUrl: String?, val entrypoint: FxAEntryPoint) : Account() + data class AuthenticationError(val operation: String, val errorCountWithinTheTimeWindow: Int = 1) : Account() { + override fun toString(): String { + return "${this.javaClass.simpleName} - $operation" + } + } + object AccessTokenKeyError : Account() + + object Logout : Account() + } + + internal sealed class Progress : Event() { + object AccountNotFound : Progress() + object AccountRestored : Progress() + + data class AuthData(val authData: FxaAuthData) : Progress() + + object FailedToBeginAuth : Progress() + object FailedToCompleteAuthRestore : Progress() + object FailedToCompleteAuth : Progress() + + object CancelAuth : Progress() + + object FailedToRecoverFromAuthenticationProblem : Progress() + object RecoveredFromAuthenticationProblem : Progress() + + object LoggedOut : Progress() + + data class StartedOAuthFlow(val oAuthUrl: String) : Progress() + data class CompletedAuthentication(val authType: AuthType) : Progress() + } + + /** + * Get a string to display in the breadcrumbs + * + * The main point of this function is to avoid using the string "auth", which gets filtered by + * Sentry. Use "ath" as a hacky replacement. + */ + fun breadcrumbDisplay(): String = when (this) { + is Account.Start -> "Account.Start" + is Account.BeginEmailFlow -> "Account.BeginEmailFlow" + is Account.BeginPairingFlow -> "Account.BeginPairingFlow" + is Account.AuthenticationError -> "Account.AthenticationError($operation)" + is Account.AccessTokenKeyError -> "Account.AccessTknKeyError" + is Account.Logout -> "Account.Logout" + is Progress.AccountNotFound -> "Progress.AccountNotFound" + is Progress.AccountRestored -> "Progress.AccountRestored" + is Progress.AuthData -> "Progress.LoggedOut" + is Progress.FailedToBeginAuth -> "Progress.FailedToBeginAth" + is Progress.FailedToCompleteAuthRestore -> "Progress.FailedToCompleteAthRestore" + is Progress.FailedToCompleteAuth -> "Progress.FailedToCompleteAth" + is Progress.CancelAuth -> "Progress.CancelAth" + is Progress.FailedToRecoverFromAuthenticationProblem -> "Progress.FailedToRecoverFromAthenticationProblem" + is Progress.RecoveredFromAuthenticationProblem -> "Progress.RecoveredFromAthenticationProblem" + is Progress.LoggedOut -> "Progress.LoggedOut" + is Progress.StartedOAuthFlow -> "Progress.StartedOAthFlow" + is Progress.CompletedAuthentication -> "Progress.CompletedAthentication" + } +} + +internal sealed class State { + data class Idle(val accountState: AccountState) : State() + data class Active(val progressState: ProgressState) : State() + + /** + * Get a string to display in the breadcrumbs + * + * The main point of this function is to avoid using the string "auth", which gets filtered by + * Sentry. Use "ath" as a hacky replacement. + */ + fun breadcrumbDisplay(): String = when (this) { + is Idle -> when (accountState) { + is AccountState.Authenticated -> "AccountState.Athenticated" + is AccountState.Authenticating -> "AccountState.Athenticating" + is AccountState.AuthenticationProblem -> "AccountState.AthenticationProblem" + is AccountState.NotAuthenticated -> "AccountState.NotAthenticated" + } + is Active -> when (progressState) { + ProgressState.Initializing -> "ProgressState.Initializing" + ProgressState.BeginningAuthentication -> "ProgressState.BeginningAthentication" + ProgressState.CompletingAuthentication -> "ProgressState.CompletingAthentication" + ProgressState.RecoveringFromAuthProblem -> "ProgressState.RecoveringFromAthProblem" + ProgressState.LoggingOut -> "ProgressState.LoggingOut" + } + } +} + +internal fun State.next(event: Event): State? = when (this) { + // Reacting to external events. + is State.Idle -> when (this.accountState) { + AccountState.NotAuthenticated -> when (event) { + Event.Account.Start -> State.Active(ProgressState.Initializing) + is Event.Account.BeginEmailFlow -> State.Active(ProgressState.BeginningAuthentication) + is Event.Account.BeginPairingFlow -> State.Active(ProgressState.BeginningAuthentication) + else -> null + } + is AccountState.Authenticating -> when (event) { + is Event.Progress.AuthData -> State.Active(ProgressState.CompletingAuthentication) + Event.Progress.CancelAuth -> State.Idle(AccountState.NotAuthenticated) + else -> null + } + AccountState.Authenticated -> when (event) { + is Event.Account.AuthenticationError -> State.Active(ProgressState.RecoveringFromAuthProblem) + is Event.Account.AccessTokenKeyError -> State.Idle(AccountState.AuthenticationProblem) + is Event.Account.Logout -> State.Active(ProgressState.LoggingOut) + else -> null + } + AccountState.AuthenticationProblem -> when (event) { + is Event.Account.Logout -> State.Active(ProgressState.LoggingOut) + is Event.Account.BeginEmailFlow -> State.Active(ProgressState.BeginningAuthentication) + else -> null + } + } + // Reacting to internal events. + is State.Active -> when (this.progressState) { + ProgressState.Initializing -> when (event) { + Event.Progress.AccountNotFound -> State.Idle(AccountState.NotAuthenticated) + Event.Progress.AccountRestored -> State.Active(ProgressState.CompletingAuthentication) + else -> null + } + ProgressState.BeginningAuthentication -> when (event) { + Event.Progress.FailedToBeginAuth -> State.Idle(AccountState.NotAuthenticated) + is Event.Progress.StartedOAuthFlow -> State.Idle(AccountState.Authenticating(event.oAuthUrl)) + else -> null + } + ProgressState.CompletingAuthentication -> when (event) { + is Event.Progress.CompletedAuthentication -> State.Idle(AccountState.Authenticated) + is Event.Account.AuthenticationError -> State.Active(ProgressState.RecoveringFromAuthProblem) + Event.Progress.FailedToCompleteAuthRestore -> State.Idle(AccountState.NotAuthenticated) + Event.Progress.FailedToCompleteAuth -> State.Idle(AccountState.NotAuthenticated) + else -> null + } + ProgressState.RecoveringFromAuthProblem -> when (event) { + Event.Progress.RecoveredFromAuthenticationProblem -> State.Idle(AccountState.Authenticated) + Event.Progress.FailedToRecoverFromAuthenticationProblem -> State.Idle(AccountState.AuthenticationProblem) + else -> null + } + ProgressState.LoggingOut -> when (event) { + Event.Progress.LoggedOut -> State.Idle(AccountState.NotAuthenticated) + else -> null + } + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/SyncEnginesStorage.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/SyncEnginesStorage.kt new file mode 100644 index 0000000000..76fcaec743 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/SyncEnginesStorage.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.fxa.manager + +import android.content.Context +import mozilla.components.service.fxa.SyncEngine +import mozilla.components.service.fxa.sync.toSyncEngine + +/** + * Storage layer for the enabled/disabled state of [SyncEngine]. + */ +class SyncEnginesStorage(private val context: Context) { + companion object { + const val SYNC_ENGINES_KEY = "syncEngines" + } + + /** + * @return A map describing known enabled/disabled state of [SyncEngine]. + */ + fun getStatus(): Map<SyncEngine, Boolean> { + val resultMap = mutableMapOf<SyncEngine, Boolean>() + // When adding new SyncEngines, think through implications for default values. + // See https://github.com/mozilla-mobile/android-components/issues/4557 + + // TODO does this need to be reversed? Go through what we have in local storage, and populate + // result map based on that. reason: we may have "other" engines. + // this will be empty if `setStatus` was never called. + storage().all.forEach { + if (it.value is Boolean) { + resultMap[it.key.toSyncEngine()] = it.value as Boolean + } + } + + return resultMap + } + + /** + * Update enabled/disabled state of [engine]. + * + * @param engine A [SyncEngine] for which to update state. + * @param status New state. + */ + fun setStatus(engine: SyncEngine, status: Boolean) { + storage().edit().putBoolean(engine.nativeName, status).apply() + } + + /** + * Clears out any stored [SyncEngine] state. + */ + internal fun clear() { + storage().edit().clear().apply() + } + + private fun storage() = context.getSharedPreferences(SYNC_ENGINES_KEY, Context.MODE_PRIVATE) +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/ext/FxaAccountManager.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/ext/FxaAccountManager.kt new file mode 100644 index 0000000000..91c71f1403 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/manager/ext/FxaAccountManager.kt @@ -0,0 +1,18 @@ +/* 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.fxa.manager.ext + +import mozilla.components.concept.sync.DeviceConstellation +import mozilla.components.concept.sync.OAuthAccount +import mozilla.components.service.fxa.manager.FxaAccountManager + +/** + * Executes [block] and provides the [DeviceConstellation] of an [OAuthAccount] if present. + */ +inline fun FxaAccountManager.withConstellation(block: DeviceConstellation.() -> Unit) { + authenticatedAccount()?.let { + block(it.deviceConstellation()) + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncAction.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncAction.kt new file mode 100644 index 0000000000..b09e7f3dcc --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncAction.kt @@ -0,0 +1,28 @@ +/* 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.fxa.store + +import mozilla.components.concept.sync.ConstellationState +import mozilla.components.lib.state.Action + +/** + * Actions for updating the global [SyncState] via [SyncStore]. + */ +sealed class SyncAction : Action { + /** + * Update the [SyncState.status] of the [SyncStore]. + */ + data class UpdateSyncStatus(val status: SyncStatus) : SyncAction() + + /** + * Update the [SyncState.account] of the [SyncStore]. + */ + data class UpdateAccount(val account: Account?) : SyncAction() + + /** + * Update the [SyncState.constellationState] of the [SyncStore]. + */ + data class UpdateDeviceConstellation(val deviceConstellation: ConstellationState) : SyncAction() +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncState.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncState.kt new file mode 100644 index 0000000000..86c9d05d2a --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncState.kt @@ -0,0 +1,63 @@ +/* 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.fxa.store + +import mozilla.components.concept.sync.Avatar +import mozilla.components.concept.sync.ConstellationState +import mozilla.components.concept.sync.OAuthAccount +import mozilla.components.concept.sync.Profile +import mozilla.components.lib.state.State +import mozilla.components.service.fxa.sync.WorkManagerSyncManager + +/** + * Global state of Sync. + * + * @property status The current status of Sync. + * @property account The current Sync account, if any. + * @property constellationState The current constellation state, if any. + */ +data class SyncState( + val status: SyncStatus = SyncStatus.NotInitialized, + val account: Account? = null, + val constellationState: ConstellationState? = null, +) : State + +/** + * Various statuses described the [SyncState]. + * + * Starts as [NotInitialized]. + * Becomes [Started] during the length of a Sync. + * Becomes [Idle] when a Sync is completed. + * Becomes [Error] when a Sync encounters an error. + * Becomes [LoggedOut] when Sync is logged out. + * + * See [WorkManagerSyncManager] for implementation details. + */ +enum class SyncStatus { + Started, + Idle, + Error, + NotInitialized, + LoggedOut, +} + +/** + * Account information available for a synced account. + * + * @property uid See [Profile.uid]. + * @property email See [Profile.email]. + * @property avatar See [Profile.avatar]. + * @property displayName See [Profile.displayName]. + * @property currentDeviceId See [OAuthAccount.getCurrentDeviceId]. + * @property sessionToken See [OAuthAccount.getSessionToken]. + */ +data class Account( + val uid: String?, + val email: String?, + val avatar: Avatar?, + val displayName: String?, + val currentDeviceId: String?, + val sessionToken: String?, +) diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStore.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStore.kt new file mode 100644 index 0000000000..50f4b6747e --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStore.kt @@ -0,0 +1,28 @@ +/* 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.fxa.store + +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.Store + +/** + * [Store] for the global [SyncState]. This should likely be a singleton. + */ +class SyncStore( + middleware: List<Middleware<SyncState, SyncAction>> = emptyList(), +) : Store<SyncState, SyncAction>( + initialState = SyncState(), + reducer = ::reduce, + middleware = middleware, +) + +private fun reduce(syncState: SyncState, syncAction: SyncAction): SyncState { + return when (syncAction) { + is SyncAction.UpdateSyncStatus -> syncState.copy(status = syncAction.status) + is SyncAction.UpdateAccount -> syncState.copy(account = syncAction.account) + is SyncAction.UpdateDeviceConstellation -> + syncState.copy(constellationState = syncAction.deviceConstellation) + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStoreSupport.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStoreSupport.kt new file mode 100644 index 0000000000..4feda96e60 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/store/SyncStoreSupport.kt @@ -0,0 +1,138 @@ +/* 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.fxa.store + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.concept.sync.AccountObserver +import mozilla.components.concept.sync.AuthType +import mozilla.components.concept.sync.ConstellationState +import mozilla.components.concept.sync.DeviceConstellationObserver +import mozilla.components.concept.sync.OAuthAccount +import mozilla.components.concept.sync.Profile +import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.service.fxa.sync.SyncStatusObserver +import java.lang.Exception + +/** + * Connections an [FxaAccountManager] with a [SyncStore], so that updates to Sync + * state can be observed. + * + * @param store The [SyncStore] to publish state updates based on [fxaAccountManager] observations. + * @param fxaAccountManager Account manager that is used to interact with Sync backends. + * @param lifecycleOwner The lifecycle owner that will tie to the when account manager observations. + * Recommended that this be an Application or at minimum a persistent Activity. + * @param autoPause Whether the account manager observations will stop between onPause and onResume. + * @param coroutineScope Scope used to launch various suspending operations. + */ +class SyncStoreSupport( + private val store: SyncStore, + private val fxaAccountManager: Lazy<FxaAccountManager>, + private val lifecycleOwner: LifecycleOwner = ProcessLifecycleOwner.get(), + private val autoPause: Boolean = false, + private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO), +) { + /** + * Initialize the integration. This will cause it to register itself as an observer + * of the [FxaAccountManager] and begin dispatching [SyncStore] updates. + */ + fun initialize() { + val accountManager = fxaAccountManager.value + accountManager.registerForSyncEvents( + AccountSyncObserver(store), + owner = lifecycleOwner, + autoPause = autoPause, + ) + + val accountObserver = FxaAccountObserver( + store, + ConstellationObserver(store), + lifecycleOwner, + autoPause, + coroutineScope, + ) + accountManager.register(accountObserver, owner = lifecycleOwner, autoPause = autoPause) + } +} + +/** + * Maps various [SyncStatusObserver] callbacks to [SyncAction] dispatches. + * + * @param store The [SyncStore] that updates will be dispatched to. + */ +internal class AccountSyncObserver(private val store: SyncStore) : SyncStatusObserver { + override fun onStarted() { + store.dispatch(SyncAction.UpdateSyncStatus(SyncStatus.Started)) + } + + override fun onIdle() { + store.dispatch(SyncAction.UpdateSyncStatus(SyncStatus.Idle)) + } + + override fun onError(error: Exception?) { + store.dispatch(SyncAction.UpdateSyncStatus(SyncStatus.Error)) + } +} + +/** + * Maps various [AccountObserver] callbacks to [SyncAction] dispatches. + * + * @param store The [SyncStore] that updates will be dispatched to. + * @param deviceConstellationObserver Will be registered as an observer to any constellations + * received in [AccountObserver.onAuthenticated]. + * + * See [SyncStoreSupport] for the rest of the param definitions. + */ +internal class FxaAccountObserver( + private val store: SyncStore, + private val deviceConstellationObserver: DeviceConstellationObserver, + private val lifecycleOwner: LifecycleOwner, + private val autoPause: Boolean, + private val coroutineScope: CoroutineScope, +) : AccountObserver { + override fun onAuthenticated(account: OAuthAccount, authType: AuthType) { + coroutineScope.launch(Dispatchers.Main) { + account.deviceConstellation().registerDeviceObserver( + deviceConstellationObserver, + owner = lifecycleOwner, + autoPause = autoPause, + ) + } + coroutineScope.launch { + val syncAccount = account.getProfile()?.toAccount(account) ?: return@launch + store.dispatch(SyncAction.UpdateAccount(syncAccount)) + } + } + + override fun onLoggedOut() { + store.dispatch(SyncAction.UpdateSyncStatus(SyncStatus.LoggedOut)) + store.dispatch(SyncAction.UpdateAccount(null)) + } +} + +/** + * Maps various [DeviceConstellationObserver] callbacks to [SyncAction] dispatches. + * + * @param store The [SyncStore] that updates will be dispatched to. + */ +internal class ConstellationObserver(private val store: SyncStore) : DeviceConstellationObserver { + override fun onDevicesUpdate(constellation: ConstellationState) { + store.dispatch(SyncAction.UpdateDeviceConstellation(constellation)) + } +} + +// Could be refactored to use a context receiver once 1.6.2 upgrade lands +private fun Profile.toAccount(oAuthAccount: OAuthAccount): Account = + Account( + uid = uid, + email = email, + avatar = avatar, + displayName = displayName, + currentDeviceId = oAuthAccount.getCurrentDeviceId(), + sessionToken = oAuthAccount.getSessionToken(), + ) diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/SyncManager.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/SyncManager.kt new file mode 100644 index 0000000000..097fce264f --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/SyncManager.kt @@ -0,0 +1,234 @@ +/* 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.fxa.sync + +import mozilla.components.concept.storage.KeyProvider +import mozilla.components.concept.sync.SyncableStore +import mozilla.components.service.fxa.SyncConfig +import mozilla.components.service.fxa.SyncEngine +import mozilla.components.service.fxa.manager.SyncEnginesStorage +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.base.observer.Observable +import mozilla.components.support.base.observer.ObserverRegistry +import java.io.Closeable +import java.lang.Exception +import java.util.concurrent.TimeUnit + +/** + * A collection of objects describing a reason for running a sync. + */ +sealed class SyncReason { + /** + * Application is starting up, and wants to sync data. + */ + object Startup : SyncReason() + + /** + * User is requesting a sync (e.g. pressed a "sync now" button). + */ + object User : SyncReason() + + /** + * User changed enabled/disabled state of one or more [SyncEngine]s. + */ + object EngineChange : SyncReason() + + /** + * Internal use only: first time running a sync. + */ + internal object FirstSync : SyncReason() + + /** + * Internal use only: running a periodic sync. + */ + internal object Scheduled : SyncReason() +} + +/** + * An interface for consumers that wish to observer "sync lifecycle" events. + */ +interface SyncStatusObserver { + /** + * Gets called at the start of a sync, before any configured syncable is synchronized. + */ + fun onStarted() + + /** + * Gets called at the end of a sync, after every configured syncable has been synchronized. + * A set of enabled [SyncEngine]s could have changed, so observers are expected to query + * [SyncEnginesStorage.getStatus]. + */ + fun onIdle() + + /** + * Gets called if sync encounters an error that's worthy of processing by status observers. + * @param error Optional relevant exception. + */ + fun onError(error: Exception?) +} + +/** + * A lazy instance of a [SyncableStore] with an optional [KeyProvider] instance, used if this store + * has encrypted contents. Lazy wrapping is in place to ensure we don't eagerly instantiate the storage. + * + * @property lazyStore A [SyncableStore] wrapped in [Lazy]. + * @property keyProvider An optional [KeyProvider] wrapped in [Lazy]. If present, it'll be used for + * crypto operations on the storage. + */ +data class LazyStoreWithKey( + val lazyStore: Lazy<SyncableStore>, + val keyProvider: Lazy<KeyProvider>? = null, +) + +/** + * A singleton registry of [SyncableStore] objects. [WorkManagerSyncDispatcher] will use this to + * access configured [SyncableStore] instances. + * + * This pattern provides a safe way for library-defined background workers to access globally + * available instances of stores within an application. + */ +object GlobalSyncableStoreProvider { + private val stores: MutableMap<SyncEngine, LazyStoreWithKey> = mutableMapOf() + + /** + * Configure an instance of [SyncableStore] for a [SyncEngine] enum. + * @param storePair A pair associating [SyncableStore] with a [SyncEngine]. + */ + fun configureStore(storePair: Pair<SyncEngine, Lazy<SyncableStore>>, keyProvider: Lazy<KeyProvider>? = null) { + stores[storePair.first] = LazyStoreWithKey(lazyStore = storePair.second, keyProvider = keyProvider) + } + + internal fun getLazyStoreWithKey(syncEngine: SyncEngine): LazyStoreWithKey? { + return stores[syncEngine] + } +} + +/** + * Internal interface to enable testing SyncManager implementations independently from SyncDispatcher. + */ +internal interface SyncDispatcher : Closeable, Observable<SyncStatusObserver> { + fun isSyncActive(): Boolean + fun syncNow( + reason: SyncReason, + debounce: Boolean = false, + customEngineSubset: List<SyncEngine> = listOf(), + ) + fun startPeriodicSync(unit: TimeUnit, period: Long, initialDelay: Long) + fun stopPeriodicSync() + fun workersStateChanged(isRunning: Boolean) +} + +/** + * A base sync manager implementation. + * @param syncConfig A [SyncConfig] object describing how sync should behave. + */ +internal abstract class SyncManager( + private val syncConfig: SyncConfig, +) { + open val logger = Logger("SyncManager") + + // A SyncDispatcher instance bound to an account and a set of syncable stores. + private var syncDispatcher: SyncDispatcher? = null + + private val syncStatusObserverRegistry = ObserverRegistry<SyncStatusObserver>() + + // Manager encapsulates events emitted by the wrapped observers, and passes them on to the outside world. + // This split allows us to define a nice public observer API (manager -> consumers), along with + // a more robust internal observer API (dispatcher -> manager). + // Currently the interfaces are the same, hence the name "pass-through". + private val dispatcherStatusObserver = PassThroughSyncStatusObserver(syncStatusObserverRegistry) + + /** + * Indicates if sync is currently running. + */ + internal fun isSyncActive() = syncDispatcher?.isSyncActive() ?: false + + internal fun registerSyncStatusObserver(observer: SyncStatusObserver) { + syncStatusObserverRegistry.register(observer) + } + + /** + * Request an immediate synchronization of all configured stores. + * + * @param reason A [SyncReason] indicating why this sync is being requested. + * @param debounce Whether or not this sync should debounced. + * @param customEngineSubset A subset of supported engines to sync. Defaults to all supported engines. + */ + internal fun now( + reason: SyncReason, + debounce: Boolean = false, + customEngineSubset: List<SyncEngine> = listOf(), + ) = synchronized(this) { + if (syncDispatcher == null) { + logger.info("Sync is not enabled. Ignoring 'sync now' request.") + } + syncDispatcher?.syncNow(reason, debounce, customEngineSubset) + } + + /** + * Enables synchronization, with behaviour described in [syncConfig]. + */ + internal fun start() = synchronized(this) { + logger.debug("Enabling...") + syncDispatcher = initDispatcher(newDispatcher(syncDispatcher, syncConfig.supportedEngines)) + logger.debug("set and initialized new dispatcher: $syncDispatcher") + } + + /** + * Disables synchronization. + */ + internal fun stop() = synchronized(this) { + logger.debug("Disabling...") + syncDispatcher?.close() + syncDispatcher = null + } + + internal abstract fun createDispatcher(supportedEngines: Set<SyncEngine>): SyncDispatcher + + internal abstract fun dispatcherUpdated(dispatcher: SyncDispatcher) + + private fun newDispatcher( + currentDispatcher: SyncDispatcher?, + supportedEngines: Set<SyncEngine>, + ): SyncDispatcher { + // Let the existing dispatcher, if present, cleanup. + currentDispatcher?.close() + // TODO will events from old and new dispatchers overlap..? How do we ensure correct sequence + // for outside observers? + currentDispatcher?.unregister(dispatcherStatusObserver) + + // Create a new dispatcher bound to current stores and account. + return createDispatcher(supportedEngines) + } + + private fun initDispatcher(dispatcher: SyncDispatcher): SyncDispatcher { + dispatcher.register(dispatcherStatusObserver) + syncConfig.periodicSyncConfig?.let { + dispatcher.startPeriodicSync( + TimeUnit.MINUTES, + period = it.periodMinutes.toLong(), + initialDelay = it.initialDelayMinutes.toLong(), + ) + } + dispatcherUpdated(dispatcher) + return dispatcher + } + + private class PassThroughSyncStatusObserver( + private val passThroughRegistry: ObserverRegistry<SyncStatusObserver>, + ) : SyncStatusObserver { + override fun onStarted() { + passThroughRegistry.notifyObservers { onStarted() } + } + + override fun onIdle() { + passThroughRegistry.notifyObservers { onIdle() } + } + + override fun onError(error: Exception?) { + passThroughRegistry.notifyObservers { onError(error) } + } + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/Types.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/Types.kt new file mode 100644 index 0000000000..243971006f --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/Types.kt @@ -0,0 +1,71 @@ +/* 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.fxa.sync + +import mozilla.appservices.syncmanager.SyncAuthInfo +import mozilla.components.service.fxa.SyncEngine + +/** + * Converts from a list of raw strings describing engines to a set of [SyncEngine] objects. + */ +fun List<String>.toSyncEngines(): Set<SyncEngine> { + return this.map { it.toSyncEngine() }.toSet() +} + +/** + * Conversion from our SyncAuthInfo into its "native" version used at the interface boundary. + */ +internal fun mozilla.components.concept.sync.SyncAuthInfo.toNative(): SyncAuthInfo { + return SyncAuthInfo( + kid = this.kid, + fxaAccessToken = this.fxaAccessToken, + syncKey = this.syncKey, + tokenserverUrl = this.tokenServerUrl, + ) +} + +internal fun String.toSyncEngine(): SyncEngine { + return when (this) { + "history" -> SyncEngine.History + "bookmarks" -> SyncEngine.Bookmarks + "passwords" -> SyncEngine.Passwords + "tabs" -> SyncEngine.Tabs + "creditcards" -> SyncEngine.CreditCards + "addresses" -> SyncEngine.Addresses + // This handles a case of engines that we don't yet model in SyncEngine. + else -> SyncEngine.Other(this) + } +} + +internal fun SyncReason.toRustSyncReason(): mozilla.appservices.syncmanager.SyncReason { + return when (this) { + SyncReason.Startup -> mozilla.appservices.syncmanager.SyncReason.STARTUP + SyncReason.User -> mozilla.appservices.syncmanager.SyncReason.USER + SyncReason.Scheduled -> mozilla.appservices.syncmanager.SyncReason.SCHEDULED + SyncReason.EngineChange -> mozilla.appservices.syncmanager.SyncReason.ENABLED_CHANGE + SyncReason.FirstSync -> mozilla.appservices.syncmanager.SyncReason.ENABLED_CHANGE + } +} + +internal fun SyncReason.asString(): String { + return when (this) { + SyncReason.FirstSync -> "first_sync" + SyncReason.Scheduled -> "scheduled" + SyncReason.EngineChange -> "engine_change" + SyncReason.User -> "user" + SyncReason.Startup -> "startup" + } +} + +internal fun String.toSyncReason(): SyncReason { + return when (this) { + "startup" -> SyncReason.Startup + "user" -> SyncReason.User + "engine_change" -> SyncReason.EngineChange + "scheduled" -> SyncReason.Scheduled + "first_sync" -> SyncReason.FirstSync + else -> throw IllegalStateException("Invalid SyncReason: $this") + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/WorkManagerSyncManager.kt b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/WorkManagerSyncManager.kt new file mode 100644 index 0000000000..eb7b1e79b0 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/main/java/mozilla/components/service/fxa/sync/WorkManagerSyncManager.kt @@ -0,0 +1,585 @@ +/* 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.fxa.sync + +import android.content.Context +import androidx.annotation.UiThread +import androidx.annotation.VisibleForTesting +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequest +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import mozilla.appservices.sync15.SyncTelemetryPing +import mozilla.appservices.syncmanager.ServiceStatus +import mozilla.appservices.syncmanager.SyncEngineSelection +import mozilla.appservices.syncmanager.SyncParams +import mozilla.appservices.syncmanager.SyncTelemetry +import mozilla.components.concept.storage.KeyProvider +import mozilla.components.service.fxa.FxaDeviceSettingsCache +import mozilla.components.service.fxa.SyncAuthInfoCache +import mozilla.components.service.fxa.SyncConfig +import mozilla.components.service.fxa.SyncEngine +import mozilla.components.service.fxa.manager.GlobalAccountManager +import mozilla.components.service.fxa.manager.SyncEnginesStorage +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.base.observer.Observable +import mozilla.components.support.base.observer.ObserverRegistry +import java.io.Closeable +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import mozilla.appservices.syncmanager.SyncManager as RustSyncManager + +@VisibleForTesting +internal enum class SyncWorkerTag { + Common, + Immediate, // will not debounce a sync + Debounce, // will debounce if another sync happened recently +} + +private enum class SyncWorkerName { + Periodic, + Immediate, +} + +private const val KEY_DATA_STORES = "stores" +private const val KEY_REASON = "reason" + +private const val SYNC_WORKER_BACKOFF_DELAY_MINUTES = 3L + +/** + * The Rust implemented SyncManager. Must be a singleton as it carries some state between + * syncs. Does no IO at creation time so is safe to call on any thread. + */ +val syncManager: RustSyncManager by lazy { RustSyncManager() } + +/** + * A [SyncManager] implementation which uses WorkManager APIs to schedule sync tasks. + * + * Must be initialized on the main thread. + */ +internal class WorkManagerSyncManager( + private val context: Context, + syncConfig: SyncConfig, +) : SyncManager(syncConfig) { + override val logger = Logger("BgSyncManager") + + init { + WorkersLiveDataObserver.init(context) + + if (syncConfig.periodicSyncConfig == null) { + logger.info("Periodic syncing is disabled.") + } else { + logger.info("Periodic syncing enabled: ${syncConfig.periodicSyncConfig}") + } + } + + override fun createDispatcher(supportedEngines: Set<SyncEngine>): SyncDispatcher { + return WorkManagerSyncDispatcher(context, supportedEngines) + } + + override fun dispatcherUpdated(dispatcher: SyncDispatcher) { + WorkersLiveDataObserver.setDispatcher(dispatcher) + } +} + +/** + * A singleton wrapper around the the LiveData "forever" observer - i.e. an observer not bound + * to a lifecycle owner. This observer is always active. + * We will have different dispatcher instances throughout the lifetime of the app, but always a + * single LiveData instance. + */ +internal object WorkersLiveDataObserver { + private lateinit var workManager: WorkManager + private val workersLiveData by lazy { + workManager.getWorkInfosByTagLiveData(SyncWorkerTag.Common.name) + } + + private var dispatcher: SyncDispatcher? = null + + /** + * Initializes the Observer. + * + * @param context the context that will be used to with the [WorkManager] to observe workers. + */ + @UiThread + fun init(context: Context) { + workManager = WorkManager.getInstance(context) + + // Only set our observer once. + if (workersLiveData.hasObservers()) return + + // This must be called on the UI thread. + workersLiveData.observeForever { + val isRunning = when (it?.any { worker -> worker.state == WorkInfo.State.RUNNING }) { + null -> false + false -> false + true -> true + } + + dispatcher?.workersStateChanged(isRunning) + + // TODO process errors coming out of worker.outputData + } + } + + fun setDispatcher(dispatcher: SyncDispatcher) { + this.dispatcher = dispatcher + } +} + +internal class WorkManagerSyncDispatcher( + private val context: Context, + private val supportedEngines: Set<SyncEngine>, +) : SyncDispatcher, Observable<SyncStatusObserver> by ObserverRegistry(), Closeable { + private val logger = Logger("WMSyncDispatcher") + + // TODO does this need to be volatile? + private var isSyncActive = false + + init { + // Stop any currently active periodic syncing. Consumers of this class are responsible for + // starting periodic syncing via [startPeriodicSync] if they need it. + stopPeriodicSync() + } + + override fun workersStateChanged(isRunning: Boolean) { + if (isSyncActive && !isRunning) { + notifyObservers { onIdle() } + isSyncActive = false + } else if (!isSyncActive && isRunning) { + notifyObservers { onStarted() } + isSyncActive = true + } + } + + override fun isSyncActive(): Boolean { + return isSyncActive + } + + override fun syncNow( + reason: SyncReason, + debounce: Boolean, + customEngineSubset: List<SyncEngine>, + ) { + logger.debug("Immediate sync requested, reason = $reason, debounce = $debounce") + val delayMs = if (reason == SyncReason.Startup) { + // Startup delay is there to avoid SQLITE_BUSY crashes, since we currently do a poor job + // of managing database connections, and we expect there to be database writes at the start. + // We've done bunch of work to make this better (see https://github.com/mozilla-mobile/android-components/issues/1369), + // but it's not clear yet this delay is completely safe to remove. + SYNC_STARTUP_DELAY_MS + } else { + 0L + } + WorkManager.getInstance(context).beginUniqueWork( + SyncWorkerName.Immediate.name, + // Use the 'keep' policy to minimize overhead from multiple "sync now" operations coming in + // at the same time. + ExistingWorkPolicy.KEEP, + regularSyncWorkRequest(reason, delayMs, debounce, customEngineSubset), + ).enqueue() + } + + override fun close() { + unregisterObservers() + stopPeriodicSync() + } + + /** + * Periodic background syncing is mainly intended to reduce workload when we sync during + * application startup. + */ + override fun startPeriodicSync(unit: TimeUnit, period: Long, initialDelay: Long) { + logger.debug("Starting periodic syncing, period = $period, time unit = $unit") + // Use the 'update' policy as a simple way to upgrade periodic worker configurations across + // application versions. We do this instead of versioning workers. + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + SyncWorkerName.Periodic.name, + ExistingPeriodicWorkPolicy.UPDATE, + periodicSyncWorkRequest(unit, period, initialDelay), + ) + } + + /** + * Disables periodic syncing in the background. Currently running syncs may continue until completion. + * Safe to call this even if periodic syncing isn't currently enabled. + */ + override fun stopPeriodicSync() { + logger.debug("Cancelling periodic syncing") + WorkManager.getInstance(context).cancelUniqueWork(SyncWorkerName.Periodic.name) + } + + private fun periodicSyncWorkRequest(unit: TimeUnit, period: Long, initialDelay: Long): PeriodicWorkRequest { + val data = getWorkerData(SyncReason.Scheduled) + // Periodic interval must be at least PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, + // e.g. not more frequently than 15 minutes. + return PeriodicWorkRequestBuilder<WorkManagerSyncWorker>(period, unit, initialDelay, unit) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ) + .setInputData(data) + .addTag(SyncWorkerTag.Common.name) + .addTag(SyncWorkerTag.Debounce.name) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + SYNC_WORKER_BACKOFF_DELAY_MINUTES, + TimeUnit.MINUTES, + ) + .build() + } + + private fun regularSyncWorkRequest( + reason: SyncReason, + delayMs: Long = 0L, + debounce: Boolean = false, + customEngineSubset: List<SyncEngine> = listOf(), + ): OneTimeWorkRequest { + val data = getWorkerData(reason, customEngineSubset) + return OneTimeWorkRequestBuilder<WorkManagerSyncWorker>() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ) + .setInputData(data) + .addTag(SyncWorkerTag.Common.name) + .addTag(if (debounce) SyncWorkerTag.Debounce.name else SyncWorkerTag.Immediate.name) + .setInitialDelay(delayMs, TimeUnit.MILLISECONDS) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + SYNC_WORKER_BACKOFF_DELAY_MINUTES, + TimeUnit.MINUTES, + ) + .build() + } + + @VisibleForTesting + internal fun getWorkerData( + reason: SyncReason, + customEngineSubset: List<SyncEngine> = listOf(), + ): Data { + val enginesToSync = customEngineSubset.takeIf { it.isNotEmpty() } ?: supportedEngines + return Data.Builder() + .putStringArray(KEY_DATA_STORES, enginesToSync.map { it.nativeName }.toTypedArray()) + .putString(KEY_REASON, reason.asString()) + .build() + } +} + +internal class WorkManagerSyncWorker( + private val context: Context, + private val params: WorkerParameters, +) : CoroutineWorker(context, params) { + private val logger = Logger("SyncWorker") + + @VisibleForTesting + internal fun isDebounced(): Boolean { + return params.tags.contains(SyncWorkerTag.Debounce.name) + } + + @VisibleForTesting + internal fun lastSyncedWithinStaggerBuffer(engine: String): Boolean { + if (!isDebounced()) { + return false + } + + return engineSyncTimestamp[engine]?.let { + (System.currentTimeMillis() - it) < SYNC_STAGGER_BUFFER_MS + } ?: false + } + + private fun updateEngineSyncedTime(engine: String) { + engineSyncTimestamp[engine] = System.currentTimeMillis() + } + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + logger.debug("Starting sync... Tagged as: ${params.tags}") + + // We will need a list of SyncableStores. + val syncableStores = params.inputData.getStringArray(KEY_DATA_STORES)?.filter { + !lastSyncedWithinStaggerBuffer(it) + }?.associate { + // Convert from a string back to our SyncEngine type. + val engine = when (it) { + SyncEngine.History.nativeName -> SyncEngine.History + SyncEngine.Bookmarks.nativeName -> SyncEngine.Bookmarks + SyncEngine.Passwords.nativeName -> SyncEngine.Passwords + SyncEngine.Tabs.nativeName -> SyncEngine.Tabs + SyncEngine.CreditCards.nativeName -> SyncEngine.CreditCards + SyncEngine.Addresses.nativeName -> SyncEngine.Addresses + else -> throw IllegalStateException("Invalid syncable store: $it") + } + + updateEngineSyncedTime(engine.nativeName) + engine to checkNotNull(GlobalSyncableStoreProvider.getLazyStoreWithKey(engine)) { + "SyncableStore missing from GlobalSyncableStoreProvider: ${engine.nativeName}" + } + } + + if (syncableStores.isNullOrEmpty()) { + // Short-circuit if there are no configured stores. + // Don't update the "last-synced" timestamp because we haven't actually synced anything. + Result.success() + } else { + doSync(syncableStores) + } + } + + @Suppress("LongMethod", "ComplexMethod") + private suspend fun doSync(syncableStores: Map<SyncEngine, LazyStoreWithKey>): Result { + val engineKeyProviders = mutableMapOf<SyncEngine, KeyProvider>() + + // We need to tell RustSyncManager which engines to sync. + val enginesToSync = SyncEngineSelection.Some(syncableStores.map { it.key.nativeName }) + + // We need to tell RustSyncManager about instances of supported stores ('places' and 'logins'). + // NOTE: This need only be done once, not each sync - but the only impact is that + // it's slightly less efficient so refactoring might not be worthwhile. + syncableStores.entries.forEach { + // We're assuming all syncable stores live in Rust. + // Currently `RustSyncManager` doesn't support non-Rust sync engines. + when (it.key) { + SyncEngine.History -> it.value.lazyStore.value.registerWithSyncManager() + SyncEngine.Bookmarks -> it.value.lazyStore.value.registerWithSyncManager() + SyncEngine.CreditCards -> { + it.value.lazyStore.value.registerWithSyncManager() + + checkNotNull(it.value.keyProvider) { + "CreditCards store must be configured with a KeyProvider" + } + + engineKeyProviders[it.key] = it.value.keyProvider!!.value + } + SyncEngine.Addresses -> { + it.value.lazyStore.value.registerWithSyncManager() + } + SyncEngine.Passwords -> { + it.value.lazyStore.value.registerWithSyncManager() + + checkNotNull(it.value.keyProvider) { + "Passwords store must be configured with a KeyProvider" + } + + engineKeyProviders[it.key] = it.value.keyProvider!!.value + } + SyncEngine.Tabs -> { + it.value.lazyStore.value.registerWithSyncManager() + } + else -> throw NotImplementedError("Unsupported engine: ${it.key}") + } + } + + // We need to know the reason we're syncing. + val reason = params.inputData.getString(KEY_REASON)!!.toSyncReason() + + // We need a cached "sync auth info" object. + val syncAuthInfo = SyncAuthInfoCache(context).getCached() ?: return Result.failure() + + // We need any persisted state that we received from RustSyncManager in the past. + // We should be able to pass a `null` value, but currently the library will crash. + // See https://github.com/mozilla/application-services/issues/1829 + val currentSyncState = getSyncState(context) ?: "" + + // We need to tell RustSyncManager about our local "which engines are enabled/disabled" state. + // This is a global state, shared by every sync client for this account. State that we will + // pass here will overwrite current global state and may be propagated to every sync client. + // This should be empty if there have been no changes to this state. + // We pass this state if user changed it (EngineChange) or if we're in a first sync situation. + // A "first sync" will happen after signing up or signing in. + // In both cases, user may have been asked which engines they'd like to sync. + val enabledChanges = when (reason) { + SyncReason.EngineChange, SyncReason.FirstSync -> { + val engineMap = SyncEnginesStorage(context).getStatus().toMutableMap() + // For historical reasons, a "history engine" really means two sync collections: history and forms. + // Underlying library doesn't manage this for us, and other clients will get confused + // if we modify just "history" without also modifying "forms", and vice versa. + // So: whenever a change to the "history" engine happens locally, inject the same "forms" change. + // This should be handled by RustSyncManager. See https://github.com/mozilla/application-services/issues/1832 + engineMap[SyncEngine.History]?.let { + engineMap[SyncEngine.Forms] = it + } + engineMap.mapKeys { it.key.nativeName } + } + else -> emptyMap() + } + + // We need to tell RustSyncManager about our current FxA device. It needs that information + // in order to sync the 'clients' collection. + // We're depending on cache being populated. An alternative to using a "cache" is to + // configure the worker with the values stored in it: device name, type and fxaDeviceID. + // While device type and fxaDeviceID are stable, users are free to change device name whenever. + // We need to reflect these changes during a sync. Our options are then: provide a global cache, + // or re-configure our workers every time a change is made to the device name. + // We have the same basic story already with syncAuthInfo cache, and a cache is much easier + // to implement/reason about than worker reconfiguration. + val deviceSettings = FxaDeviceSettingsCache(context).getCached()!! + + // Obtain encryption keys for stores that came along with KeyProviders. + // This can take a bit of time! + val localEncryptionKeys = engineKeyProviders.mapKeys { + it.key.nativeName + }.mapValues { + it.value.getOrGenerateKey().key + } + + // We're now ready to sync. + val syncParams = SyncParams( + reason = reason.toRustSyncReason(), + engines = enginesToSync, + authInfo = syncAuthInfo.toNative(), + enabledChanges = enabledChanges, + persistedState = currentSyncState, + deviceSettings = deviceSettings, + localEncryptionKeys = localEncryptionKeys, + ) + + val syncResult = syncManager.sync(syncParams) + + // Persist the sync state; it may have changed during a sync, and RustSyncManager relies on us + // to store it. + setSyncState(context, syncResult.persistedState) + + // Log the results. + syncResult.failures.entries.forEach { + logger.error("Failed to sync ${it.key}, reason: ${it.value}") + } + syncResult.successful.forEach { + logger.info("Successfully synced $it") + } + + // Process any changes to the list of declined/accepted engines. + // NB: We may have received engine state information about an engine we're unfamiliar with. + // `SyncEngine.Other(string)` stands in for unknown engines. + val declinedEngines = syncResult.declined?.map { it.toSyncEngine() }?.toSet() ?: emptySet() + // We synthesize the 'accepted' list ourselves: engines we know about - declined engines. + // This assumes that "engines we know about" is a subset of engines RustSyncManager knows about. + // RustSyncManager will handle this, eventually. + // See: https://github.com/mozilla/application-services/issues/1685 + val acceptedEngines = syncableStores.keys.filter { !declinedEngines.contains(it) } + + // Persist engine state changes. + with(SyncEnginesStorage(context)) { + declinedEngines.forEach { setStatus(it, status = false) } + acceptedEngines.forEach { setStatus(it, status = true) } + } + + // Process telemetry. + syncResult.telemetryJson?.let { SyncTelemetry.processSyncTelemetry(SyncTelemetryPing.fromJSONString(it)) } + + // Finally, declare success, failure or request a retry based on 'sync status'. + return when (syncResult.status) { + // Happy case. + ServiceStatus.OK -> { + // Worker should set the "last-synced" timestamp, and since we have a single timestamp, + // it's not clear if a single failure should prevent its update. That's the current behaviour + // in Fennec, but for very specific reasons that aren't relevant here. We could have + // a timestamp per store, or whatever we want here really. + // For now, we just update it every time we succeed to sync. + setLastSynced(context, System.currentTimeMillis()) + Result.success() + } + + // Retry cases. + // NB: retry doesn't mean "immediate retry". It means "retry, but respecting this worker's + // backoff policy, as configured during worker's creation. + // TODO FOR ALL retries: look at workerParams.mRunAttemptCount, don't retry after a certain number. + ServiceStatus.NETWORK_ERROR -> { + logger.error("Network error") + Result.retry() + } + ServiceStatus.BACKED_OFF -> { + logger.error("Backed-off error") + // As part of `syncResult`, we get back `nextSyncAllowedAt`. Ideally, we should not retry + // before that passes. However, we can not reconfigure back-off policy for an already + // created Worker. So, we just rely on a sensible default. `RustSyncManager` will fail + // to sync with a BACKED_OFF error without hitting the server if we don't respect + // `nextSyncAllowedAt`, so we should be good either way. + Result.retry() + } + + // Failure cases. + ServiceStatus.AUTH_ERROR -> { + logger.error("Auth error") + GlobalAccountManager.authError("RustSyncManager.sync", forSync = true) + Result.failure() + } + ServiceStatus.SERVICE_ERROR -> { + logger.error("Service error") + Result.failure() + } + ServiceStatus.OTHER_ERROR -> { + logger.error("'Other' error :(") + Result.failure() + } + } + } + + companion object { + @VisibleForTesting + internal const val SYNC_STAGGER_BUFFER_MS = 5 * 1000L // 5 seconds. + + @VisibleForTesting + internal val engineSyncTimestamp = ConcurrentHashMap<String, Long>() + } +} + +private const val SYNC_STATE_PREFS_KEY = "syncPrefs" +private const val SYNC_LAST_SYNCED_KEY = "lastSynced" +private const val SYNC_STATE_KEY = "persistedState" + +private const val SYNC_STARTUP_DELAY_MS = 5 * 1000L // 5 seconds. + +fun getLastSynced(context: Context): Long { + return context + .getSharedPreferences(SYNC_STATE_PREFS_KEY, Context.MODE_PRIVATE) + .getLong(SYNC_LAST_SYNCED_KEY, 0) +} + +internal fun clearSyncState(context: Context) { + context.getSharedPreferences(SYNC_STATE_PREFS_KEY, Context.MODE_PRIVATE) + .edit().clear().apply() +} + +internal fun getSyncState(context: Context): String? { + return context + .getSharedPreferences(SYNC_STATE_PREFS_KEY, Context.MODE_PRIVATE) + .getString(SYNC_STATE_KEY, null) +} + +/** + * Saves the lastSyncedTime to the shared preferences + * + * @param context the context + * @param lastSyncedTime - the last synced time in milliseconds + */ +fun setLastSynced(context: Context, lastSyncedTime: Long) { + context + .getSharedPreferences(SYNC_STATE_PREFS_KEY, Context.MODE_PRIVATE) + .edit() + .putLong(SYNC_LAST_SYNCED_KEY, lastSyncedTime) + .apply() +} + +internal fun setSyncState(context: Context, state: String) { + context + .getSharedPreferences(SYNC_STATE_PREFS_KEY, Context.MODE_PRIVATE) + .edit() + .putString(SYNC_STATE_KEY, state) + .apply() +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/AccountStorageTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/AccountStorageTest.kt new file mode 100644 index 0000000000..bb72c1276a --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/AccountStorageTest.kt @@ -0,0 +1,166 @@ +/* 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.fxa + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.appservices.fxaclient.FxaConfig +import mozilla.appservices.fxaclient.FxaServer +import mozilla.components.concept.base.crash.CrashReporting +import mozilla.components.lib.dataprotect.SecureAbove22Preferences +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.reset +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.robolectric.annotation.Config +import kotlin.reflect.KClass + +// Note that tests that involve secure storage specify API=21, because of issues testing secure storage on +// 23+ API levels. See https://github.com/mozilla-mobile/android-components/issues/4956 + +@RunWith(AndroidJUnit4::class) +class SharedPrefAccountStorageTest { + @Config(sdk = [21]) + @Test + fun `plain storage crud`() { + val storage = SharedPrefAccountStorage(testContext) + val account = FirefoxAccount( + FxaConfig(FxaServer.Release, "someId", "http://www.firefox.com"), + ) + assertNull(storage.read()) + storage.write(account.toJSONString()) + assertNotNull(storage.read()) + storage.clear() + assertNull(storage.read()) + } + + @Config(sdk = [21]) + @Test + fun `migration from SecureAbove22AccountStorage`() { + val secureStorage = SecureAbove22AccountStorage(testContext) + val account = FirefoxAccount( + FxaConfig(FxaServer.Release, "someId", "http://www.firefox.com"), + ) + + assertNull(secureStorage.read()) + secureStorage.write(account.toJSONString()) + assertNotNull(secureStorage.read()) + + // Now that we have account state in secureStorage, it should be migrated over to plainStorage when it's init'd. + val plainStorage = SharedPrefAccountStorage(testContext) + assertNotNull(plainStorage.read()) + // And secureStorage must have been cleared during this migration. + assertNull(secureStorage.read()) + } + + @Config(sdk = [21]) + @Test + fun `missing state is reported during a migration`() { + val secureStorage = SecureAbove22AccountStorage(testContext) + val account = FirefoxAccount( + FxaConfig(FxaServer.Release, "someId", "http://www.firefox.com"), + ) + secureStorage.write(account.toJSONString()) + + // Clear the underlying storage layer "behind the back" of account storage. + SecureAbove22Preferences(testContext, "fxaStateAC").clear() + + val crashReporter: CrashReporting = mock() + val plainStorage = SharedPrefAccountStorage(testContext, crashReporter) + assertCaughtException(crashReporter, AbnormalAccountStorageEvent.UnexpectedlyMissingAccountState::class) + + assertNull(plainStorage.read()) + + reset(crashReporter) + assertNull(secureStorage.read()) + verifyNoInteractions(crashReporter) + } +} + +@RunWith(AndroidJUnit4::class) +class SecureAbove22AccountStorageTest { + @Config(sdk = [21]) + @Test + fun `secure storage crud`() { + val crashReporter: CrashReporting = mock() + val storage = SecureAbove22AccountStorage(testContext, crashReporter) + val account = FirefoxAccount( + FxaConfig(FxaServer.Release, "someId", "http://www.firefox.com"), + ) + assertNull(storage.read()) + storage.write(account.toJSONString()) + assertNotNull(storage.read()) + storage.clear() + assertNull(storage.read()) + verifyNoInteractions(crashReporter) + } + + @Config(sdk = [21]) + @Test + fun `migration from SharedPrefAccountStorage`() { + val plainStorage = SharedPrefAccountStorage(testContext) + val account = FirefoxAccount( + FxaConfig(FxaServer.Release, "someId", "http://www.firefox.com"), + ) + + assertNull(plainStorage.read()) + plainStorage.write(account.toJSONString()) + assertNotNull(plainStorage.read()) + + // Now that we have account state in plainStorage, it should be migrated over to secureStorage when it's init'd. + val crashReporter: CrashReporting = mock() + val secureStorage = SecureAbove22AccountStorage(testContext, crashReporter) + assertNotNull(secureStorage.read()) + // And plainStorage must have been cleared during this migration. + assertNull(plainStorage.read()) + verifyNoInteractions(crashReporter) + } + + @Config(sdk = [21]) + @Test + fun `missing state is reported`() { + val crashReporter: CrashReporting = mock() + val storage = SecureAbove22AccountStorage(testContext, crashReporter) + val account = FirefoxAccount( + FxaConfig(FxaServer.Release, "someId", "http://www.firefox.com"), + ) + storage.write(account.toJSONString()) + + // Clear the underlying storage layer "behind the back" of account storage. + SecureAbove22Preferences(testContext, "fxaStateAC").clear() + assertNull(storage.read()) + assertCaughtException(crashReporter, AbnormalAccountStorageEvent.UnexpectedlyMissingAccountState::class) + // Make sure exception is only reported once per "incident". + reset(crashReporter) + assertNull(storage.read()) + verifyNoInteractions(crashReporter) + } + + @Config(sdk = [21]) + @Test + fun `missing state is ignored without a configured crash reporter`() { + val storage = SecureAbove22AccountStorage(testContext) + val account = FirefoxAccount( + FxaConfig(FxaServer.Release, "someId", "http://www.firefox.com"), + ) + storage.write(account.toJSONString()) + + // Clear the underlying storage layer "behind the back" of account storage. + SecureAbove22Preferences(testContext, "fxaStateAC").clear() + assertNull(storage.read()) + } +} + +private fun <T : AbnormalAccountStorageEvent> assertCaughtException(crashReporter: CrashReporting, type: KClass<T>) { + val captor = argumentCaptor<AbnormalAccountStorageEvent>() + verify(crashReporter).submitCaughtException(captor.capture()) + Assert.assertEquals(type, captor.value::class) +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaAccountManagerTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaAccountManagerTest.kt new file mode 100644 index 0000000000..afc2fc3ee9 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaAccountManagerTest.kt @@ -0,0 +1,1646 @@ +/* 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.fxa + +import android.content.Context +import androidx.lifecycle.LifecycleOwner +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import mozilla.appservices.fxaclient.FxaConfig +import mozilla.appservices.fxaclient.FxaServer +import mozilla.components.concept.base.crash.CrashReporting +import mozilla.components.concept.sync.AccessTokenInfo +import mozilla.components.concept.sync.AccountEventsObserver +import mozilla.components.concept.sync.AccountObserver +import mozilla.components.concept.sync.AuthFlowUrl +import mozilla.components.concept.sync.AuthType +import mozilla.components.concept.sync.DeviceCapability +import mozilla.components.concept.sync.DeviceConfig +import mozilla.components.concept.sync.DeviceConstellation +import mozilla.components.concept.sync.DeviceType +import mozilla.components.concept.sync.FxAEntryPoint +import mozilla.components.concept.sync.OAuthAccount +import mozilla.components.concept.sync.OAuthScopedKey +import mozilla.components.concept.sync.Profile +import mozilla.components.concept.sync.ServiceResult +import mozilla.components.concept.sync.StatePersistenceCallback +import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.service.fxa.manager.GlobalAccountManager +import mozilla.components.service.fxa.manager.SyncEnginesStorage +import mozilla.components.service.fxa.sync.SyncDispatcher +import mozilla.components.service.fxa.sync.SyncManager +import mozilla.components.service.fxa.sync.SyncReason +import mozilla.components.service.fxa.sync.SyncStatusObserver +import mozilla.components.support.base.observer.Observable +import mozilla.components.support.base.observer.ObserverRegistry +import mozilla.components.support.test.any +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.eq +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.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.never +import org.mockito.Mockito.reset +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions +import org.mockito.Mockito.`when` +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext + +internal fun testAuthFlowUrl(entrypoint: String = "test-entrypoint"): AuthFlowUrl { + return AuthFlowUrl(EXPECTED_AUTH_STATE, "https://example.com/auth-flow-start?entrypiont=$entrypoint&state=$EXPECTED_AUTH_STATE") +} + +internal class TestableStorageWrapper( + manager: FxaAccountManager, + accountEventObserverRegistry: ObserverRegistry<AccountEventsObserver>, + serverConfig: FxaConfig, + private val block: () -> OAuthAccount = { + val account: OAuthAccount = mock() + `when`(account.deviceConstellation()).thenReturn(mock()) + account + }, +) : StorageWrapper(manager, accountEventObserverRegistry, serverConfig) { + override fun obtainAccount(): OAuthAccount = block() +} + +// Same as the actual account manager, except we get to control how FirefoxAccountShaped instances +// are created. This is necessary because due to some build issues (native dependencies not available +// within the test environment) we can't use fxaclient supplied implementation of FirefoxAccountShaped. +// Instead, we express all of our account-related operations over an interface. +internal open class TestableFxaAccountManager( + context: Context, + config: FxaConfig, + private val storage: AccountStorage, + capabilities: Set<DeviceCapability> = emptySet(), + syncConfig: SyncConfig? = null, + coroutineContext: CoroutineContext, + crashReporter: CrashReporting? = null, + block: () -> OAuthAccount = { + val account: OAuthAccount = mock() + `when`(account.deviceConstellation()).thenReturn(mock()) + account + }, +) : FxaAccountManager(context, config, DeviceConfig("test", DeviceType.UNKNOWN, capabilities), syncConfig, emptySet(), crashReporter, coroutineContext) { + private val testableStorageWrapper = TestableStorageWrapper(this, accountEventObserverRegistry, serverConfig, block) + + override var syncStatusObserverRegistry = ObserverRegistry<SyncStatusObserver>() + + override fun getStorageWrapper(): StorageWrapper { + return testableStorageWrapper + } + + override fun getAccountStorage(): AccountStorage { + return storage + } + + override fun createSyncManager(config: SyncConfig): SyncManager = mock() +} + +const val EXPECTED_AUTH_STATE = "goodAuthState" +const val UNEXPECTED_AUTH_STATE = "badAuthState" + +@ExperimentalCoroutinesApi // for runTest +@RunWith(AndroidJUnit4::class) +class FxaAccountManagerTest { + + val entryPoint: FxAEntryPoint = mock<FxAEntryPoint>().apply { + whenever(entryName).thenReturn("home-menu") + } + + @After + fun cleanup() { + SyncAuthInfoCache(testContext).clear() + SyncEnginesStorage(testContext).clear() + } + + internal class TestSyncDispatcher(registry: ObserverRegistry<SyncStatusObserver>) : SyncDispatcher, Observable<SyncStatusObserver> by registry { + val inner: SyncDispatcher = mock() + override fun isSyncActive(): Boolean { + return inner.isSyncActive() + } + + override fun syncNow( + reason: SyncReason, + debounce: Boolean, + customEngineSubset: List<SyncEngine>, + ) { + inner.syncNow(reason, debounce, customEngineSubset) + } + + override fun startPeriodicSync(unit: TimeUnit, period: Long, initialDelay: Long) { + inner.startPeriodicSync(unit, period, initialDelay) + } + + override fun stopPeriodicSync() { + inner.stopPeriodicSync() + } + + override fun workersStateChanged(isRunning: Boolean) { + inner.workersStateChanged(isRunning) + } + + override fun close() { + inner.close() + } + } + + internal class TestSyncManager(config: SyncConfig) : SyncManager(config) { + val dispatcherRegistry = ObserverRegistry<SyncStatusObserver>() + val dispatcher: TestSyncDispatcher = TestSyncDispatcher(dispatcherRegistry) + + private var dispatcherUpdatedCount = 0 + override fun createDispatcher(supportedEngines: Set<SyncEngine>): SyncDispatcher { + return dispatcher + } + + override fun dispatcherUpdated(dispatcher: SyncDispatcher) { + dispatcherUpdatedCount++ + } + } + + class TestSyncStatusObserver : SyncStatusObserver { + var onStartedCount = 0 + var onIdleCount = 0 + var onErrorCount = 0 + + override fun onStarted() { + onStartedCount++ + } + + override fun onIdle() { + onIdleCount++ + } + + override fun onError(error: Exception?) { + onErrorCount++ + } + } + + @Test + fun `restored account state persistence`() = runTest { + val accountStorage: AccountStorage = mock() + val profile = Profile("testUid", "test@example.com", null, "Test Profile") + val constellation: DeviceConstellation = mockDeviceConstellation() + val account = StatePersistenceTestableAccount(profile, constellation) + + val manager = TestableFxaAccountManager( + testContext, + FxaConfig(FxaServer.Release, "dummyId", "http://auth-url/redirect"), + accountStorage, + setOf(DeviceCapability.SEND_TAB), + null, + this.coroutineContext, + ) { + account + } + + `when`(constellation.finalizeDevice(eq(AuthType.Existing), any())).thenReturn(ServiceResult.Ok) + // We have an account at the start. + `when`(accountStorage.read()).thenReturn(account) + + assertNull(account.persistenceCallback) + manager.start() + + // Assert that persistence callback is set. + assertNotNull(account.persistenceCallback) + + // Assert that ensureCapabilities fired, but not the device initialization (since we're restoring). + verify(constellation).finalizeDevice(eq(AuthType.Existing), any()) + + // Assert that persistence callback is interacting with the storage layer. + account.persistenceCallback!!.persist("test") + verify(accountStorage).write("test") + } + + @Test + fun `restored account state persistence, finalizeDevice hit an intermittent error`() = runTest { + val accountStorage: AccountStorage = mock() + val profile = Profile("testUid", "test@example.com", null, "Test Profile") + val constellation: DeviceConstellation = mockDeviceConstellation() + val account = StatePersistenceTestableAccount(profile, constellation) + + val manager = TestableFxaAccountManager( + testContext, + FxaConfig(FxaServer.Release, "dummyId", "http://auth-url/redirect"), + accountStorage, + setOf(DeviceCapability.SEND_TAB), + null, + this.coroutineContext, + ) { + account + } + + `when`(constellation.finalizeDevice(eq(AuthType.Existing), any())).thenReturn(ServiceResult.OtherError) + // We have an account at the start. + `when`(accountStorage.read()).thenReturn(account) + + assertNull(account.persistenceCallback) + manager.start() + + // Assert that persistence callback is set. + assertNotNull(account.persistenceCallback) + + // Assert that finalizeDevice fired with a correct auth type. 3 times since we re-try. + verify(constellation, times(3)).finalizeDevice(eq(AuthType.Existing), any()) + + // Assert that persistence callback is interacting with the storage layer. + account.persistenceCallback!!.persist("test") + verify(accountStorage).write("test") + + // Since we weren't able to finalize the account state, we're no longer authenticated. + assertNull(manager.authenticatedAccount()) + } + + @Test + fun `restored account state persistence, hit an auth error`() = runTest { + val accountStorage: AccountStorage = mock() + val profile = Profile("testUid", "test@example.com", null, "Test Profile") + val constellation: DeviceConstellation = mockDeviceConstellation() + val account = StatePersistenceTestableAccount(profile, constellation, ableToRecoverFromAuthError = false) + + val accountObserver: AccountObserver = mock() + val manager = TestableFxaAccountManager( + testContext, + FxaConfig(FxaServer.Release, "dummyId", "http://auth-url/redirect"), + accountStorage, + setOf(DeviceCapability.SEND_TAB), + null, + this.coroutineContext, + ) { + account + } + + manager.register(accountObserver) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.AuthError) + // We have an account at the start. + `when`(accountStorage.read()).thenReturn(account) + + assertNull(account.persistenceCallback) + + assertFalse(manager.accountNeedsReauth()) + assertFalse(account.authErrorDetectedCalled) + assertFalse(account.checkAuthorizationStatusCalled) + verify(accountObserver, never()).onAuthenticationProblems() + + manager.start() + + assertTrue(manager.accountNeedsReauth()) + verify(accountObserver, times(1)).onAuthenticationProblems() + assertTrue(account.authErrorDetectedCalled) + assertTrue(account.checkAuthorizationStatusCalled) + } + + @Test(expected = FxaPanicException::class) + fun `restored account state persistence, hit an fxa panic which is re-thrown`() = runTest { + val accountStorage: AccountStorage = mock() + val profile = Profile("testUid", "test@example.com", null, "Test Profile") + val constellation: DeviceConstellation = mock() + val account = StatePersistenceTestableAccount(profile, constellation) + + val accountObserver: AccountObserver = mock() + val manager = TestableFxaAccountManager( + testContext, + FxaConfig(FxaServer.Release, "dummyId", "http://auth-url/redirect"), + accountStorage, + setOf(DeviceCapability.SEND_TAB), + null, + this.coroutineContext, + ) { + account + } + + manager.register(accountObserver) + + // Hit a panic while we're restoring account. + doAnswer { + throw FxaPanicException("don't panic!") + }.`when`(constellation).finalizeDevice(any(), any()) + + // We have an account at the start. + `when`(accountStorage.read()).thenReturn(account) + + assertNull(account.persistenceCallback) + + assertFalse(manager.accountNeedsReauth()) + verify(accountObserver, never()).onAuthenticationProblems() + + manager.start() + } + + @Test + fun `newly authenticated account state persistence`() = runTest { + val accountStorage: AccountStorage = mock() + val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") + val constellation: DeviceConstellation = mockDeviceConstellation() + val account = StatePersistenceTestableAccount(profile, constellation) + val accountObserver: AccountObserver = mock() + // We are not using the "prepareHappy..." helper method here, because our account isn't a mock, + // but an actual implementation of the interface. + val manager = TestableFxaAccountManager( + testContext, + FxaConfig(FxaServer.Release, "dummyId", "bad://url"), + accountStorage, + setOf(DeviceCapability.SEND_TAB), + null, + this.coroutineContext, + ) { + account + } + + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + + // There's no account at the start. + `when`(accountStorage.read()).thenReturn(null) + + manager.register(accountObserver) + + // Kick it off, we'll get into a "NotAuthenticated" state. + manager.start() + + // Perform authentication. + + assertEquals(testAuthFlowUrl(entrypoint = "home-menu").url, manager.beginAuthentication(entrypoint = entryPoint)) + + manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)) + assertTrue(manager.authenticatedAccount() != null) + + // Assert that initDevice fired, but not ensureCapabilities (since we're initing a new account). + verify(constellation).finalizeDevice(eq(AuthType.Signin), any()) + + // Assert that persistence callback is interacting with the storage layer. + account.persistenceCallback!!.persist("test") + verify(accountStorage).write("test") + } + + @Test + fun `auth state verification while finishing authentication`() = runTest { + val accountStorage: AccountStorage = mock() + val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") + val constellation: DeviceConstellation = mockDeviceConstellation() + val account = StatePersistenceTestableAccount(profile, constellation) + val accountObserver: AccountObserver = mock() + // We are not using the "prepareHappy..." helper method here, because our account isn't a mock, + // but an actual implementation of the interface. + val manager = TestableFxaAccountManager( + testContext, + FxaConfig(FxaServer.Release, "dummyId", "bad://url"), + accountStorage, + setOf(DeviceCapability.SEND_TAB), + null, + this.coroutineContext, + ) { + account + } + + // There's no account at the start. + `when`(accountStorage.read()).thenReturn(null) + + manager.register(accountObserver) + // Kick it off, we'll get into a "NotAuthenticated" state. + manager.start() + + // Attempt to finish authentication without starting it first. + manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", UNEXPECTED_AUTH_STATE)) + assertTrue(manager.authenticatedAccount() == null) + + // Start authentication. StatePersistenceTestableAccount will produce state=EXPECTED_AUTH_STATE. + assertEquals(testAuthFlowUrl(entrypoint = "home-menu").url, manager.beginAuthentication(entrypoint = entryPoint)) + + // Now attempt to finish it with a correct state. + `when`(constellation.finalizeDevice(eq(AuthType.Signin), any())).thenReturn(ServiceResult.Ok) + manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)) + assertTrue(manager.authenticatedAccount() != null) + + // Assert that manager is authenticated. + assertEquals(account, manager.authenticatedAccount()) + } + + class StatePersistenceTestableAccount( + private val profile: Profile, + private val constellation: DeviceConstellation, + val ableToRecoverFromAuthError: Boolean = false, + val tokenServerEndpointUrl: String? = null, + val accessToken: (() -> AccessTokenInfo)? = null, + ) : OAuthAccount { + + var persistenceCallback: StatePersistenceCallback? = null + var checkAuthorizationStatusCalled = false + var authErrorDetectedCalled = false + + override suspend fun beginOAuthFlow(scopes: Set<String>, entryPoint: FxAEntryPoint): AuthFlowUrl? { + return AuthFlowUrl(EXPECTED_AUTH_STATE, testAuthFlowUrl(entrypoint = entryPoint.entryName).url) + } + + override suspend fun beginPairingFlow(pairingUrl: String, scopes: Set<String>, entryPoint: FxAEntryPoint): AuthFlowUrl? { + return AuthFlowUrl(EXPECTED_AUTH_STATE, testAuthFlowUrl(entrypoint = entryPoint.entryName).url) + } + + override suspend fun getProfile(ignoreCache: Boolean): Profile? { + return profile + } + + override fun getCurrentDeviceId(): String? { + return "testFxaDeviceId" + } + + override fun getSessionToken(): String? { + return null + } + + override suspend fun completeOAuthFlow(code: String, state: String): Boolean { + return true + } + + override suspend fun getAccessToken(singleScope: String): AccessTokenInfo? { + val token = accessToken?.invoke() + if (token != null) return token + + fail() + return null + } + + override fun authErrorDetected() { + authErrorDetectedCalled = true + } + + override suspend fun checkAuthorizationStatus(singleScope: String): Boolean? { + checkAuthorizationStatusCalled = true + return ableToRecoverFromAuthError + } + + override suspend fun getTokenServerEndpointURL(): String? { + if (tokenServerEndpointUrl != null) return tokenServerEndpointUrl + + fail() + return "" + } + + override suspend fun getManageAccountURL(entryPoint: FxAEntryPoint): String? { + return "https://firefox.com/settings" + } + + override fun getPairingAuthorityURL(): String { + return "https://firefox.com/pair" + } + + override fun registerPersistenceCallback(callback: StatePersistenceCallback) { + persistenceCallback = callback + } + + override fun deviceConstellation(): DeviceConstellation { + return constellation + } + + override suspend fun disconnect(): Boolean { + return true + } + + override fun toJSONString(): String { + fail() + return "" + } + + override fun close() { + // Only expect 'close' to be called if we can't recover from an auth error. + if (ableToRecoverFromAuthError) { + fail() + } + } + } + + @Test + fun `error reading persisted account`() = runTest { + val accountStorage = mock<AccountStorage>() + val readException = FxaNetworkException("pretend we failed to fetch the account") + `when`(accountStorage.read()).thenThrow(readException) + + val manager = TestableFxaAccountManager( + testContext, + FxaConfig(FxaServer.Release, "dummyId", "bad://url"), + accountStorage, + coroutineContext = this.coroutineContext, + ) + + val accountObserver = object : AccountObserver { + override fun onLoggedOut() { + fail() + } + + override fun onAuthenticated(account: OAuthAccount, authType: AuthType) { + fail() + } + + override fun onAuthenticationProblems() { + fail() + } + + override fun onProfileUpdated(profile: Profile) { + fail() + } + } + + manager.register(accountObserver) + manager.start() + } + + @Test + fun `no persisted account`() = runTest { + val accountStorage = mock<AccountStorage>() + // There's no account at the start. + `when`(accountStorage.read()).thenReturn(null) + + val manager = TestableFxaAccountManager( + testContext, + FxaConfig(FxaServer.Release, "dummyId", "bad://url"), + accountStorage, + coroutineContext = this.coroutineContext, + ) + + val accountObserver: AccountObserver = mock() + + manager.register(accountObserver) + manager.start() + + verify(accountObserver, never()).onAuthenticated(any(), any()) + verify(accountObserver, never()).onProfileUpdated(any()) + verify(accountObserver, never()).onLoggedOut() + + verify(accountStorage, times(1)).read() + verify(accountStorage, never()).write(any()) + verify(accountStorage, never()).clear() + + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + } + + @Test + fun `with persisted account and profile`() = runTest { + val accountStorage = mock<AccountStorage>() + val mockAccount: OAuthAccount = mock() + val constellation: DeviceConstellation = mock() + val profile = Profile( + "testUid", + "test@example.com", + null, + "Test Profile", + ) + `when`(mockAccount.getProfile(ignoreCache = false)).thenReturn(profile) + // We have an account at the start. + `when`(accountStorage.read()).thenReturn(mockAccount) + `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + `when`(constellation.finalizeDevice(eq(AuthType.Existing), any())).thenReturn(ServiceResult.Ok) + + val manager = TestableFxaAccountManager( + testContext, + FxaConfig(FxaServer.Release, "dummyId", "bad://url"), + accountStorage, + emptySet(), + null, + this.coroutineContext, + ) + + val accountObserver: AccountObserver = mock() + + manager.register(accountObserver) + + manager.start() + + // Make sure that account and profile observers are fired exactly once. + verify(accountObserver, times(1)).onAuthenticated(mockAccount, AuthType.Existing) + verify(accountObserver, times(1)).onProfileUpdated(profile) + verify(accountObserver, never()).onLoggedOut() + + verify(accountStorage, times(1)).read() + verify(accountStorage, never()).write(any()) + verify(accountStorage, never()).clear() + + assertEquals(mockAccount, manager.authenticatedAccount()) + assertEquals(profile, manager.accountProfile()) + + // Make sure 'logoutAsync' clears out state and fires correct observers. + reset(accountObserver) + reset(accountStorage) + `when`(mockAccount.disconnect()).thenReturn(true) + + // Simulate SyncManager populating SyncEnginesStorage with some state. + SyncEnginesStorage(testContext).setStatus(SyncEngine.History, true) + SyncEnginesStorage(testContext).setStatus(SyncEngine.Passwords, false) + assertTrue(SyncEnginesStorage(testContext).getStatus().isNotEmpty()) + + verify(mockAccount, never()).disconnect() + manager.logout() + + assertTrue(SyncEnginesStorage(testContext).getStatus().isEmpty()) + verify(accountObserver, never()).onAuthenticated(any(), any()) + verify(accountObserver, never()).onProfileUpdated(any()) + verify(accountObserver, times(1)).onLoggedOut() + verify(mockAccount, times(1)).disconnect() + + verify(accountStorage, never()).read() + verify(accountStorage, never()).write(any()) + verify(accountStorage, times(1)).clear() + + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + } + + @Test + fun `happy authentication and profile flow`() = runTest { + val mockAccount: OAuthAccount = mock() + val constellation: DeviceConstellation = mock() + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") + val accountStorage = mock<AccountStorage>() + val accountObserver: AccountObserver = mock() + val manager = prepareHappyAuthenticationFlow(mockAccount, profile, accountStorage, accountObserver, this.coroutineContext) + + // We start off as logged-out, but the event won't be called (initial default state is assumed). + verify(accountObserver, never()).onLoggedOut() + verify(accountObserver, never()).onAuthenticated(any(), any()) + + reset(accountObserver) + assertEquals(testAuthFlowUrl(entrypoint = "home-menu").url, manager.beginAuthentication(entrypoint = entryPoint)) + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + + `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + + manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)) + assertTrue(manager.authenticatedAccount() != null) + + verify(accountStorage, times(1)).read() + verify(accountStorage, never()).clear() + + verify(accountObserver, times(1)).onAuthenticated(mockAccount, AuthType.Signin) + verify(accountObserver, times(1)).onProfileUpdated(profile) + verify(accountObserver, never()).onLoggedOut() + + assertEquals(mockAccount, manager.authenticatedAccount()) + assertEquals(profile, manager.accountProfile()) + + val cachedAuthInfo = SyncAuthInfoCache(testContext).getCached() + assertNotNull(cachedAuthInfo) + assertEquals("kid", cachedAuthInfo!!.kid) + assertEquals("someToken", cachedAuthInfo.fxaAccessToken) + assertEquals("k", cachedAuthInfo.syncKey) + assertEquals("some://url", cachedAuthInfo.tokenServerUrl) + assertTrue(cachedAuthInfo.fxaAccessTokenExpiresAt > 0) + } + + @Test(expected = FxaPanicException::class) + fun `fxa panic during initDevice flow`() = runTest { + val mockAccount: OAuthAccount = mock() + val constellation: DeviceConstellation = mock() + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") + val accountStorage = mock<AccountStorage>() + val accountObserver: AccountObserver = mock() + val manager = prepareHappyAuthenticationFlow(mockAccount, profile, accountStorage, accountObserver, this.coroutineContext) + + // We start off as logged-out, but the event won't be called (initial default state is assumed). + verify(accountObserver, never()).onLoggedOut() + verify(accountObserver, never()).onAuthenticated(any(), any()) + + reset(accountObserver) + assertEquals(testAuthFlowUrl(entrypoint = "home-menu").url, manager.beginAuthentication(entrypoint = entryPoint)) + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + doAnswer { + throw FxaPanicException("Don't panic!") + }.`when`(constellation).finalizeDevice(any(), any()) + + manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)) + assertTrue(manager.authenticatedAccount() != null) + } + + @Test(expected = FxaPanicException::class) + fun `fxa panic during pairing flow`() = runTest { + val mockAccount: OAuthAccount = mock() + `when`(mockAccount.deviceConstellation()).thenReturn(mock()) + val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") + val accountStorage = mock<AccountStorage>() + `when`(mockAccount.getProfile(ignoreCache = false)).thenReturn(profile) + + doAnswer { + throw FxaPanicException("Don't panic!") + }.`when`(mockAccount).beginPairingFlow(any(), any(), any()) + `when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true) + // There's no account at the start. + `when`(accountStorage.read()).thenReturn(null) + + val manager = TestableFxaAccountManager( + testContext, + FxaConfig(FxaServer.Release, "dummyId", "bad://url"), + accountStorage, + coroutineContext = coroutineContext, + ) { + mockAccount + } + + manager.start() + manager.beginAuthentication("http://pairing.com", entryPoint) + fail() + } + + @Test + fun `happy pairing authentication and profile flow`() = runTest { + val mockAccount: OAuthAccount = mock() + val constellation: DeviceConstellation = mock() + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") + val accountStorage = mock<AccountStorage>() + val accountObserver: AccountObserver = mock() + val manager = prepareHappyAuthenticationFlow(mockAccount, profile, accountStorage, accountObserver, this.coroutineContext) + + // We start off as logged-out, but the event won't be called (initial default state is assumed). + verify(accountObserver, never()).onLoggedOut() + verify(accountObserver, never()).onAuthenticated(any(), any()) + + reset(accountObserver) + assertEquals(testAuthFlowUrl(entrypoint = "home-menu").url, manager.beginAuthentication(pairingUrl = "auth://pairing", entryPoint)) + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + + `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + + manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)) + assertTrue(manager.authenticatedAccount() != null) + + verify(accountStorage, times(1)).read() + verify(accountStorage, never()).clear() + + verify(accountObserver, times(1)).onAuthenticated(mockAccount, AuthType.Signin) + verify(accountObserver, times(1)).onProfileUpdated(profile) + verify(accountObserver, never()).onLoggedOut() + + assertEquals(mockAccount, manager.authenticatedAccount()) + assertEquals(profile, manager.accountProfile()) + } + + @Test + fun `repeated unfinished authentication attempts succeed`() = runTest { + val mockAccount: OAuthAccount = mock() + val constellation: DeviceConstellation = mock() + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") + val accountStorage = mock<AccountStorage>() + val accountObserver: AccountObserver = mock() + val manager = prepareHappyAuthenticationFlow(mockAccount, profile, accountStorage, accountObserver, this.coroutineContext) + + // We start off as logged-out, but the event won't be called (initial default state is assumed). + verify(accountObserver, never()).onLoggedOut() + verify(accountObserver, never()).onAuthenticated(any(), any()) + + // Begin auth for the first time. + reset(accountObserver) + assertEquals( + testAuthFlowUrl(entrypoint = "home-menu").url, + manager.beginAuthentication( + pairingUrl = "auth://pairing", + entrypoint = entryPoint, + ), + ) + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + + // Now, try to begin again before finishing the first one. + assertEquals( + testAuthFlowUrl(entrypoint = "home-menu").url, + manager.beginAuthentication( + pairingUrl = "auth://pairing", + entrypoint = entryPoint, + ), + ) + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + + // The rest should "just work". + `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + + manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)) + assertTrue(manager.authenticatedAccount() != null) + + verify(accountStorage, times(1)).read() + verify(accountStorage, never()).clear() + + verify(accountObserver, times(1)).onAuthenticated(mockAccount, AuthType.Signin) + verify(accountObserver, times(1)).onProfileUpdated(profile) + verify(accountObserver, never()).onLoggedOut() + + assertEquals(mockAccount, manager.authenticatedAccount()) + assertEquals(profile, manager.accountProfile()) + } + + @Test + fun `unhappy authentication flow`() = runTest { + val accountStorage = mock<AccountStorage>() + val mockAccount: OAuthAccount = mock() + val constellation: DeviceConstellation = mock() + val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") + val accountObserver: AccountObserver = mock() + val manager = prepareUnhappyAuthenticationFlow(mockAccount, profile, accountStorage, accountObserver, this.coroutineContext) + + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") + + // We start off as logged-out, but the event won't be called (initial default state is assumed). + verify(accountObserver, never()).onLoggedOut() + verify(accountObserver, never()).onAuthenticated(any(), any()) + + reset(accountObserver) + + assertNull(manager.beginAuthentication(entrypoint = entryPoint)) + + // Confirm that account state observable doesn't receive authentication errors. + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + + // Try again, without any network problems this time. + `when`(mockAccount.beginOAuthFlow(any(), any())).thenReturn(testAuthFlowUrl()) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + + assertEquals(testAuthFlowUrl().url, manager.beginAuthentication(entrypoint = entryPoint)) + + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + verify(accountStorage, times(1)).clear() + + manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)) + assertTrue(manager.authenticatedAccount() != null) + + verify(accountStorage, times(1)).read() + verify(accountStorage, times(1)).clear() + + verify(accountObserver, times(1)).onAuthenticated(mockAccount, AuthType.Signin) + verify(accountObserver, times(1)).onProfileUpdated(profile) + verify(accountObserver, never()).onLoggedOut() + + assertEquals(mockAccount, manager.authenticatedAccount()) + assertEquals(profile, manager.accountProfile()) + } + + @Test + fun `unhappy pairing authentication flow`() = runTest { + val accountStorage = mock<AccountStorage>() + val mockAccount: OAuthAccount = mock() + val constellation: DeviceConstellation = mock() + val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") + val accountObserver: AccountObserver = mock() + val manager = prepareUnhappyAuthenticationFlow(mockAccount, profile, accountStorage, accountObserver, this.coroutineContext) + + `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + + // We start off as logged-out, but the event won't be called (initial default state is assumed). + verify(accountObserver, never()).onLoggedOut() + verify(accountObserver, never()).onAuthenticated(any(), any()) + + reset(accountObserver) + + assertNull(manager.beginAuthentication(pairingUrl = "auth://pairing", entrypoint = entryPoint)) + + // Confirm that account state observable doesn't receive authentication errors. + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + + // Try again, without any network problems this time. + `when`( + mockAccount.beginPairingFlow( + anyString(), + any(), + any(), + ), + ).thenReturn(testAuthFlowUrl()) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + + assertEquals( + testAuthFlowUrl().url, + manager.beginAuthentication( + pairingUrl = "auth://pairing", + entrypoint = entryPoint, + ), + ) + + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + verify(accountStorage, times(1)).clear() + + manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)) + assertTrue(manager.authenticatedAccount() != null) + + verify(accountStorage, times(1)).read() + verify(accountStorage, times(1)).clear() + + verify(accountObserver, times(1)).onAuthenticated(mockAccount, AuthType.Signin) + verify(accountObserver, times(1)).onProfileUpdated(profile) + verify(accountObserver, never()).onLoggedOut() + + assertEquals(mockAccount, manager.authenticatedAccount()) + assertEquals(profile, manager.accountProfile()) + } + + @Test + fun `authentication issues are propagated via AccountObserver`() = runTest { + val mockAccount: OAuthAccount = mock() + val constellation: DeviceConstellation = mock() + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") + val accountStorage = mock<AccountStorage>() + val accountObserver: AccountObserver = mock() + val manager = prepareHappyAuthenticationFlow(mockAccount, profile, accountStorage, accountObserver, this.coroutineContext) + + // We start off as logged-out, but the event won't be called (initial default state is assumed). + verify(accountObserver, never()).onLoggedOut() + verify(accountObserver, never()).onAuthenticated(any(), any()) + + reset(accountObserver) + assertEquals(testAuthFlowUrl(entrypoint = "home-menu").url, manager.beginAuthentication(entrypoint = entryPoint)) + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + + manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)) + assertTrue(manager.authenticatedAccount() != null) + + verify(accountObserver, never()).onAuthenticationProblems() + assertFalse(manager.accountNeedsReauth()) + + // Our recovery flow should attempt to hit this API. Model the "can't recover" condition by returning 'false'. + `when`(mockAccount.checkAuthorizationStatus(eq("profile"))).thenReturn(false) + + // At this point, we're logged in. Trigger a 401. + manager.encounteredAuthError("a test") + + verify(accountObserver, times(1)).onAuthenticationProblems() + assertTrue(manager.accountNeedsReauth()) + assertEquals(mockAccount, manager.authenticatedAccount()) + + // Make sure profile is still available. + assertEquals(profile, manager.accountProfile()) + + // Able to re-authenticate. + reset(accountObserver) + assertEquals(testAuthFlowUrl(entrypoint = "home-menu").url, manager.beginAuthentication(entrypoint = entryPoint)) + + manager.finishAuthentication(FxaAuthData(AuthType.Pairing, "dummyCode", EXPECTED_AUTH_STATE)) + assertTrue(manager.authenticatedAccount() != null) + + verify(accountObserver).onAuthenticated(mockAccount, AuthType.Pairing) + verify(accountObserver, never()).onAuthenticationProblems() + assertFalse(manager.accountNeedsReauth()) + assertEquals(profile, manager.accountProfile()) + } + + @Test + fun `authentication issues are recoverable via checkAuthorizationState`() = runTest { + val mockAccount: OAuthAccount = mock() + val constellation: DeviceConstellation = mock() + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") + val accountStorage = mock<AccountStorage>() + val accountObserver: AccountObserver = mock() + val crashReporter: CrashReporting = mock() + val manager = prepareHappyAuthenticationFlow( + mockAccount, + profile, + accountStorage, + accountObserver, + this.coroutineContext, + setOf(DeviceCapability.SEND_TAB), + crashReporter, + ) + + // We start off as logged-out, but the event won't be called (initial default state is assumed). + verify(accountObserver, never()).onLoggedOut() + verify(accountObserver, never()).onAuthenticated(any(), any()) + + reset(accountObserver) + assertEquals(testAuthFlowUrl(entrypoint = "home-menu").url, manager.beginAuthentication(entrypoint = entryPoint)) + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + + `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + `when`(constellation.refreshDevices()).thenReturn(true) + + manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)) + assertTrue(manager.authenticatedAccount() != null) + + verify(accountObserver, never()).onAuthenticationProblems() + assertFalse(manager.accountNeedsReauth()) + + // Recovery flow will hit this API, and will recover if it returns 'true'. + `when`(mockAccount.checkAuthorizationStatus(eq("profile"))).thenReturn(true) + + // At this point, we're logged in. Trigger a 401. + manager.encounteredAuthError("a test") + assertRecovered(true, "a test", constellation, accountObserver, manager, mockAccount, crashReporter) + } + + @Test + fun `authentication recovery flow has a circuit breaker`() = runTest { + val mockAccount: OAuthAccount = mock() + val constellation: DeviceConstellation = mock() + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile") + val accountStorage = mock<AccountStorage>() + val accountObserver: AccountObserver = mock() + val crashReporter: CrashReporting = mock() + val manager = prepareHappyAuthenticationFlow( + mockAccount, + profile, + accountStorage, + accountObserver, + this.coroutineContext, + setOf(DeviceCapability.SEND_TAB), + crashReporter, + ) + GlobalAccountManager.setInstance(manager) + + // We start off as logged-out, but the event won't be called (initial default state is assumed). + verify(accountObserver, never()).onLoggedOut() + verify(accountObserver, never()).onAuthenticated(any(), any()) + + reset(accountObserver) + assertEquals(testAuthFlowUrl(entrypoint = "home-menu").url, manager.beginAuthentication(entrypoint = entryPoint)) + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + + `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + `when`(constellation.refreshDevices()).thenReturn(true) + + manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)) + assertTrue(manager.authenticatedAccount() != null) + + verify(accountObserver, never()).onAuthenticationProblems() + assertFalse(manager.accountNeedsReauth()) + + // Recovery flow will hit this API, and will recover if it returns 'true'. + `when`(mockAccount.checkAuthorizationStatus(eq("profile"))).thenReturn(true) + + // At this point, we're logged in. Trigger a 401 for the first time. + manager.encounteredAuthError("a test") + // ... and just for good measure, trigger another 401 to simulate overlapping API calls legitimately hitting 401s. + manager.encounteredAuthError("a test", errorCountWithinTheTimeWindow = 3) + assertRecovered(true, "a test", constellation, accountObserver, manager, mockAccount, crashReporter) + + // We've fully recovered by now, let's hit another 401 sometime later (count has been reset). + manager.encounteredAuthError("a test") + assertRecovered(true, "a test", constellation, accountObserver, manager, mockAccount, crashReporter) + + // Suddenly, we're in a bad loop, expect to hit our circuit-breaker here. + manager.encounteredAuthError("another test", errorCountWithinTheTimeWindow = 50) + assertRecovered(false, "another test", constellation, accountObserver, manager, mockAccount, crashReporter) + } + + private suspend fun assertRecovered( + success: Boolean, + operation: String, + constellation: DeviceConstellation, + accountObserver: AccountObserver, + manager: FxaAccountManager, + mockAccount: OAuthAccount, + crashReporter: CrashReporting, + ) { + // During recovery, only 'sign-in' finalize device call should have been made. + verify(constellation, times(1)).finalizeDevice(eq(AuthType.Signin), any()) + verify(constellation, never()).finalizeDevice(eq(AuthType.Recovered), any()) + + assertEquals(mockAccount, manager.authenticatedAccount()) + + if (success) { + // Since we've recovered, outside observers should not have witnessed the momentary problem state. + verify(accountObserver, never()).onAuthenticationProblems() + assertFalse(manager.accountNeedsReauth()) + verify(crashReporter, never()).submitCaughtException(any()) + } else { + // We were unable to recover, outside observers should have been told. + verify(accountObserver, times(1)).onAuthenticationProblems() + assertTrue(manager.accountNeedsReauth()) + + val captor = argumentCaptor<Throwable>() + verify(crashReporter).submitCaughtException(captor.capture()) + assertEquals("Auth recovery circuit breaker triggered by: $operation", captor.value.message) + assertTrue(captor.value is AccountManagerException.AuthRecoveryCircuitBreakerException) + } + } + + @Test + fun `unhappy profile fetching flow`() = runTest { + val accountStorage = mock<AccountStorage>() + val mockAccount: OAuthAccount = mock() + val constellation: DeviceConstellation = mock() + + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + `when`(mockAccount.getProfile(ignoreCache = false)).thenReturn(null) + `when`(mockAccount.beginOAuthFlow(any(), any())).thenReturn(testAuthFlowUrl()) + `when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true) + // There's no account at the start. + `when`(accountStorage.read()).thenReturn(null) + + val manager = TestableFxaAccountManager( + testContext, + FxaConfig(FxaServer.Release, "dummyId", "bad://url"), + accountStorage, + coroutineContext = this.coroutineContext, + ) { + mockAccount + } + + val accountObserver: AccountObserver = mock() + + manager.register(accountObserver) + manager.start() + + // We start off as logged-out, but the event won't be called (initial default state is assumed). + verify(accountObserver, never()).onLoggedOut() + verify(accountObserver, never()).onAuthenticated(any(), any()) + + reset(accountObserver) + assertEquals(testAuthFlowUrl().url, manager.beginAuthentication(entrypoint = entryPoint)) + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + + manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)) + assertTrue(manager.authenticatedAccount() != null) + + verify(accountStorage, times(1)).read() + verify(accountStorage, never()).clear() + + verify(accountObserver, times(1)).onAuthenticated(mockAccount, AuthType.Signin) + verify(accountObserver, never()).onProfileUpdated(any()) + verify(accountObserver, never()).onLoggedOut() + + assertEquals(mockAccount, manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + + // Make sure we can re-try fetching a profile. This time, let's have it succeed. + reset(accountObserver) + val profile = Profile( + uid = "testUID", + avatar = null, + email = "test@example.com", + displayName = "test profile", + ) + + `when`(mockAccount.getProfile(ignoreCache = true)).thenReturn(profile) + assertNull(manager.accountProfile()) + assertEquals(profile, manager.refreshProfile(true)) + + verify(accountObserver, times(1)).onProfileUpdated(profile) + verify(accountObserver, never()).onAuthenticated(any(), any()) + verify(accountObserver, never()).onLoggedOut() + } + + @Test + fun `profile fetching flow hit an unrecoverable auth problem`() = runTest { + val accountStorage = mock<AccountStorage>() + val mockAccount: OAuthAccount = mock() + val constellation: DeviceConstellation = mock() + + `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + + // Our recovery flow should attempt to hit this API. Model the "can't recover" condition by returning false. + `when`(mockAccount.checkAuthorizationStatus(eq("profile"))).thenReturn(false) + + `when`(mockAccount.beginOAuthFlow(any(), any())).thenReturn(testAuthFlowUrl()) + `when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true) + // There's no account at the start. + `when`(accountStorage.read()).thenReturn(null) + + val manager = TestableFxaAccountManager( + testContext, + FxaConfig(FxaServer.Release, "dummyId", "bad://url"), + accountStorage, + coroutineContext = this.coroutineContext, + ) { + mockAccount + } + + lateinit var waitFor: Job + `when`(mockAccount.getProfile(ignoreCache = false)).then { + // Hit an auth error. + waitFor = CoroutineScope(coroutineContext).launch { manager.encounteredAuthError("a test") } + null + } + + val accountObserver: AccountObserver = mock() + + manager.register(accountObserver) + manager.start() + + // We start off as logged-out, but the event won't be called (initial default state is assumed). + verify(accountObserver, never()).onLoggedOut() + verify(accountObserver, never()).onAuthenticated(any(), any()) + verify(accountObserver, never()).onAuthenticationProblems() + verify(mockAccount, never()).checkAuthorizationStatus(any()) + assertFalse(manager.accountNeedsReauth()) + + reset(accountObserver) + assertEquals(testAuthFlowUrl().url, manager.beginAuthentication(entrypoint = entryPoint)) + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + + manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)) + + waitFor.join() + assertTrue(manager.accountNeedsReauth()) + verify(accountObserver, times(1)).onAuthenticationProblems() + verify(mockAccount, times(1)).checkAuthorizationStatus(eq("profile")) + Unit + } + + @Test + fun `profile fetching flow hit an unrecoverable auth problem for which we can't determine a recovery state`() = runTest { + val accountStorage = mock<AccountStorage>() + val mockAccount: OAuthAccount = mock() + val constellation: DeviceConstellation = mock() + + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + + // Our recovery flow should attempt to hit this API. Model the "don't know what's up" condition by returning null. + `when`(mockAccount.checkAuthorizationStatus(eq("profile"))).thenReturn(null) + + `when`(mockAccount.beginOAuthFlow(any(), any())).thenReturn(testAuthFlowUrl()) + `when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true) + // There's no account at the start. + `when`(accountStorage.read()).thenReturn(null) + + val manager = TestableFxaAccountManager( + testContext, + FxaConfig(FxaServer.Release, "dummyId", "bad://url"), + accountStorage, + coroutineContext = this.coroutineContext, + ) { + mockAccount + } + + lateinit var waitFor: Job + `when`(mockAccount.getProfile(ignoreCache = false)).then { + // Hit an auth error. + waitFor = CoroutineScope(coroutineContext).launch { manager.encounteredAuthError("a test") } + null + } + + val accountObserver: AccountObserver = mock() + + manager.register(accountObserver) + manager.start() + + // We start off as logged-out, but the event won't be called (initial default state is assumed). + verify(accountObserver, never()).onLoggedOut() + verify(accountObserver, never()).onAuthenticated(any(), any()) + verify(accountObserver, never()).onAuthenticationProblems() + verify(mockAccount, never()).checkAuthorizationStatus(any()) + assertFalse(manager.accountNeedsReauth()) + + reset(accountObserver) + assertEquals(testAuthFlowUrl().url, manager.beginAuthentication(entrypoint = entryPoint)) + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + + manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)) + assertTrue(manager.authenticatedAccount() != null) + + waitFor.join() + assertTrue(manager.accountNeedsReauth()) + verify(accountObserver, times(1)).onAuthenticationProblems() + verify(mockAccount, times(1)).checkAuthorizationStatus(eq("profile")) + Unit + } + + @Test + fun `profile fetching flow hit a recoverable auth problem`() = runTest { + val accountStorage = mock<AccountStorage>() + val mockAccount: OAuthAccount = mock() + val constellation: DeviceConstellation = mock() + val captor = argumentCaptor<AuthType>() + + `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + + val profile = Profile( + uid = "testUID", + avatar = null, + email = "test@example.com", + displayName = "test profile", + ) + + // Recovery flow will hit this API, return a success. + `when`(mockAccount.checkAuthorizationStatus(eq("profile"))).thenReturn(true) + + `when`(mockAccount.beginOAuthFlow(any(), any())).thenReturn(testAuthFlowUrl()) + `when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true) + // There's no account at the start. + `when`(accountStorage.read()).thenReturn(null) + + val manager = TestableFxaAccountManager( + testContext, + FxaConfig(FxaServer.Release, "dummyId", "bad://url"), + accountStorage, + coroutineContext = this.coroutineContext, + ) { + mockAccount + } + + var didFailProfileFetch = false + lateinit var waitFor: Job + `when`(mockAccount.getProfile(ignoreCache = false)).then { + // Hit an auth error, but only once. As we recover from it, we'll attempt to fetch a profile + // again. At that point, we'd like to succeed. + if (!didFailProfileFetch) { + didFailProfileFetch = true + waitFor = CoroutineScope(coroutineContext).launch { manager.encounteredAuthError("a test") } + null + } else { + profile + } + } + // Upon recovery, we'll hit an 'ignore cache' path. + `when`(mockAccount.getProfile(ignoreCache = true)).thenReturn(profile) + + val accountObserver: AccountObserver = mock() + + manager.register(accountObserver) + manager.start() + + // We start off as logged-out, but the event won't be called (initial default state is assumed). + verify(accountObserver, never()).onLoggedOut() + verify(accountObserver, never()).onAuthenticated(any(), any()) + verify(accountObserver, never()).onAuthenticationProblems() + verify(mockAccount, never()).checkAuthorizationStatus(any()) + assertFalse(manager.accountNeedsReauth()) + + reset(accountObserver) + assertEquals(testAuthFlowUrl().url, manager.beginAuthentication(entrypoint = entryPoint)) + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + + manager.finishAuthentication(FxaAuthData(AuthType.Signup, "dummyCode", EXPECTED_AUTH_STATE)) + assertTrue(manager.authenticatedAccount() != null) + waitFor.join() + assertFalse(manager.accountNeedsReauth()) + assertEquals(mockAccount, manager.authenticatedAccount()) + assertEquals(profile, manager.accountProfile()) + verify(accountObserver, never()).onAuthenticationProblems() + // Once for the initial auth success, then once again after we recover from an auth problem. + verify(accountObserver, times(2)).onAuthenticated(eq(mockAccount), captor.capture()) + assertEquals(AuthType.Signup, captor.allValues[0]) + assertEquals(AuthType.Recovered, captor.allValues[1]) + // Verify that we went through the recovery flow. + verify(mockAccount, times(1)).checkAuthorizationStatus(eq("profile")) + Unit + } + + @Test(expected = FxaPanicException::class) + fun `profile fetching flow hit an fxa panic, which is re-thrown`() = runTest { + val accountStorage = mock<AccountStorage>() + val mockAccount: OAuthAccount = mock() + val constellation: DeviceConstellation = mock() + + `when`(mockAccount.getCurrentDeviceId()).thenReturn("testDeviceId") + `when`(mockAccount.deviceConstellation()).thenReturn(constellation) + `when`(constellation.finalizeDevice(any(), any())).thenReturn(ServiceResult.Ok) + doAnswer { + throw FxaPanicException("500") + }.`when`(mockAccount).getProfile(ignoreCache = false) + `when`(mockAccount.beginOAuthFlow(any(), any())).thenReturn(testAuthFlowUrl()) + `when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true) + // There's no account at the start. + `when`(accountStorage.read()).thenReturn(null) + + val manager = TestableFxaAccountManager( + testContext, + FxaConfig(FxaServer.Release, "dummyId", "bad://url"), + accountStorage, + coroutineContext = this.coroutineContext, + ) { + mockAccount + } + + val accountObserver: AccountObserver = mock() + + manager.register(accountObserver) + manager.start() + + // We start off as logged-out, but the event won't be called (initial default state is assumed). + verify(accountObserver, never()).onLoggedOut() + verify(accountObserver, never()).onAuthenticated(any(), any()) + verify(accountObserver, never()).onAuthenticationProblems() + assertFalse(manager.accountNeedsReauth()) + + reset(accountObserver) + assertEquals(testAuthFlowUrl().url, manager.beginAuthentication(entrypoint = entryPoint)) + assertNull(manager.authenticatedAccount()) + assertNull(manager.accountProfile()) + + manager.finishAuthentication(FxaAuthData(AuthType.Signin, "dummyCode", EXPECTED_AUTH_STATE)) + assertTrue(manager.authenticatedAccount() != null) + } + + @Test + fun `accounts to sync integration`() { + val syncManager: SyncManager = mock() + val integration = FxaAccountManager.AccountsToSyncIntegration(syncManager) + + // onAuthenticated - mapping of AuthType to SyncReason + integration.onAuthenticated(mock(), AuthType.Signin) + verify(syncManager, times(1)).start() + verify(syncManager, times(1)).now(eq(SyncReason.FirstSync), anyBoolean(), eq(listOf())) + integration.onAuthenticated(mock(), AuthType.Signup) + verify(syncManager, times(2)).start() + verify(syncManager, times(2)).now(eq(SyncReason.FirstSync), anyBoolean(), eq(listOf())) + integration.onAuthenticated(mock(), AuthType.Pairing) + verify(syncManager, times(3)).start() + verify(syncManager, times(3)).now(eq(SyncReason.FirstSync), anyBoolean(), eq(listOf())) + integration.onAuthenticated(mock(), AuthType.MigratedReuse) + verify(syncManager, times(4)).start() + verify(syncManager, times(4)).now(eq(SyncReason.FirstSync), anyBoolean(), eq(listOf())) + integration.onAuthenticated(mock(), AuthType.OtherExternal("test")) + verify(syncManager, times(5)).start() + verify(syncManager, times(5)).now(eq(SyncReason.FirstSync), anyBoolean(), eq(listOf())) + integration.onAuthenticated(mock(), AuthType.Existing) + verify(syncManager, times(6)).start() + verify(syncManager, times(1)).now(eq(SyncReason.Startup), anyBoolean(), eq(listOf())) + integration.onAuthenticated(mock(), AuthType.Recovered) + verify(syncManager, times(7)).start() + verify(syncManager, times(2)).now(eq(SyncReason.Startup), anyBoolean(), eq(listOf())) + verifyNoMoreInteractions(syncManager) + + // onProfileUpdated - no-op + integration.onProfileUpdated(mock()) + verifyNoMoreInteractions(syncManager) + + // onAuthenticationProblems + integration.onAuthenticationProblems() + verify(syncManager).stop() + verifyNoMoreInteractions(syncManager) + + // onLoggedOut + integration.onLoggedOut() + verify(syncManager, times(2)).stop() + verifyNoMoreInteractions(syncManager) + } + + @Test + fun `GIVEN a sync observer WHEN registering it THEN add it to the sync observer registry`() { + val fxaManager = TestableFxaAccountManager( + context = testContext, + config = mock(), + storage = mock(), + capabilities = setOf(DeviceCapability.SEND_TAB), + syncConfig = null, + coroutineContext = mock(), + ) + fxaManager.syncStatusObserverRegistry = mock() + val observer: SyncStatusObserver = mock() + val lifecycleOwner: LifecycleOwner = mock() + + fxaManager.registerForSyncEvents(observer, lifecycleOwner, false) + + verify(fxaManager.syncStatusObserverRegistry).register(observer, lifecycleOwner, false) + verifyNoMoreInteractions(fxaManager.syncStatusObserverRegistry) + } + + @Test + fun `GIVEN a sync observer WHEN unregistering it THEN remove it from the sync observer registry`() { + val fxaManager = TestableFxaAccountManager( + context = testContext, + config = mock(), + storage = mock(), + capabilities = setOf(DeviceCapability.SEND_TAB), + syncConfig = null, + coroutineContext = mock(), + ) + fxaManager.syncStatusObserverRegistry = mock() + val observer: SyncStatusObserver = mock() + + fxaManager.unregisterForSyncEvents(observer) + + verify(fxaManager.syncStatusObserverRegistry).unregister(observer) + verifyNoMoreInteractions(fxaManager.syncStatusObserverRegistry) + } + + private suspend fun prepareHappyAuthenticationFlow( + mockAccount: OAuthAccount, + profile: Profile, + accountStorage: AccountStorage, + accountObserver: AccountObserver, + coroutineContext: CoroutineContext, + capabilities: Set<DeviceCapability> = emptySet(), + crashReporter: CrashReporting? = null, + ): FxaAccountManager { + val accessTokenInfo = AccessTokenInfo( + "testSc0pe", + "someToken", + OAuthScopedKey("kty", "testSc0pe", "kid", "k"), + System.currentTimeMillis() + 1000 * 10, + ) + + `when`(mockAccount.getProfile(ignoreCache = false)).thenReturn(profile) + `when`(mockAccount.beginOAuthFlow(any(), any())).thenReturn(testAuthFlowUrl(entrypoint = "home-menu")) + `when`(mockAccount.beginPairingFlow(anyString(), any(), any())).thenReturn(testAuthFlowUrl(entrypoint = "home-menu")) + `when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true) + `when`(mockAccount.getAccessToken(anyString())).thenReturn(accessTokenInfo) + `when`(mockAccount.getTokenServerEndpointURL()).thenReturn("some://url") + + // There's no account at the start. + `when`(accountStorage.read()).thenReturn(null) + + val manager = TestableFxaAccountManager( + testContext, + FxaConfig(FxaServer.Release, "dummyId", "bad://url"), + accountStorage, + capabilities, + SyncConfig(setOf(SyncEngine.History, SyncEngine.Bookmarks), PeriodicSyncConfig()), + coroutineContext = coroutineContext, + crashReporter = crashReporter, + ) { + mockAccount + } + + manager.register(accountObserver) + manager.start() + + return manager + } + + private suspend fun prepareUnhappyAuthenticationFlow( + mockAccount: OAuthAccount, + profile: Profile, + accountStorage: AccountStorage, + accountObserver: AccountObserver, + coroutineContext: CoroutineContext, + ): FxaAccountManager { + `when`(mockAccount.getProfile(ignoreCache = false)).thenReturn(profile) + `when`(mockAccount.deviceConstellation()).thenReturn(mock()) + `when`(mockAccount.beginOAuthFlow(any(), any())).thenReturn(null) + `when`(mockAccount.beginPairingFlow(anyString(), any(), any())).thenReturn(null) + `when`(mockAccount.completeOAuthFlow(anyString(), anyString())).thenReturn(true) + // There's no account at the start. + `when`(accountStorage.read()).thenReturn(null) + + val manager = TestableFxaAccountManager( + testContext, + FxaConfig(FxaServer.Release, "dummyId", "bad://url"), + accountStorage, + coroutineContext = coroutineContext, + ) { + mockAccount + } + + manager.register(accountObserver) + + manager.start() + + return manager + } + + private suspend fun mockDeviceConstellation(): DeviceConstellation { + val c: DeviceConstellation = mock() + `when`(c.refreshDevices()).thenReturn(true) + return c + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaDeviceConstellationTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaDeviceConstellationTest.kt new file mode 100644 index 0000000000..3132e0d752 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaDeviceConstellationTest.kt @@ -0,0 +1,498 @@ +/* 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.fxa + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.plus +import mozilla.appservices.fxaclient.FxaException +import mozilla.appservices.fxaclient.IncomingDeviceCommand +import mozilla.appservices.fxaclient.SendTabPayload +import mozilla.appservices.fxaclient.TabHistoryEntry +import mozilla.appservices.syncmanager.DeviceSettings +import mozilla.components.concept.sync.AccountEvent +import mozilla.components.concept.sync.AccountEventsObserver +import mozilla.components.concept.sync.AuthType +import mozilla.components.concept.sync.ConstellationState +import mozilla.components.concept.sync.DeviceCapability +import mozilla.components.concept.sync.DeviceCommandIncoming +import mozilla.components.concept.sync.DeviceCommandOutgoing +import mozilla.components.concept.sync.DeviceConfig +import mozilla.components.concept.sync.DeviceConstellationObserver +import mozilla.components.concept.sync.DevicePushSubscription +import mozilla.components.concept.sync.DeviceType +import mozilla.components.concept.sync.TabData +import mozilla.components.support.test.any +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.never +import org.mockito.Mockito.reset +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.`when` +import mozilla.appservices.fxaclient.AccountEvent as ASAccountEvent +import mozilla.appservices.fxaclient.Device as NativeDevice +import mozilla.appservices.fxaclient.DevicePushSubscription as NativeDevicePushSubscription +import mozilla.appservices.fxaclient.FxaClient as NativeFirefoxAccount +import mozilla.appservices.sync15.DeviceType as RustDeviceType + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class FxaDeviceConstellationTest { + lateinit var account: NativeFirefoxAccount + lateinit var constellation: FxaDeviceConstellation + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + + @Before + fun setup() { + account = mock() + val scope = CoroutineScope(coroutinesTestRule.testDispatcher) + SupervisorJob() + constellation = FxaDeviceConstellation(account, scope, mock()) + } + + @Test + fun `finalize device`() = runTestOnMain { + fun expectedFinalizeAction(authType: AuthType): FxaDeviceConstellation.DeviceFinalizeAction = when (authType) { + AuthType.Existing -> FxaDeviceConstellation.DeviceFinalizeAction.EnsureCapabilities + AuthType.Signin -> FxaDeviceConstellation.DeviceFinalizeAction.Initialize + AuthType.Signup -> FxaDeviceConstellation.DeviceFinalizeAction.Initialize + AuthType.Pairing -> FxaDeviceConstellation.DeviceFinalizeAction.Initialize + is AuthType.OtherExternal -> FxaDeviceConstellation.DeviceFinalizeAction.Initialize + AuthType.MigratedCopy -> FxaDeviceConstellation.DeviceFinalizeAction.Initialize + AuthType.MigratedReuse -> FxaDeviceConstellation.DeviceFinalizeAction.EnsureCapabilities + AuthType.Recovered -> FxaDeviceConstellation.DeviceFinalizeAction.None + } + fun initAuthType(simpleClassName: String): AuthType = when (simpleClassName) { + "Existing" -> AuthType.Existing + "Signin" -> AuthType.Signin + "Signup" -> AuthType.Signup + "Pairing" -> AuthType.Pairing + "OtherExternal" -> AuthType.OtherExternal("test") + "MigratedCopy" -> AuthType.MigratedCopy + "MigratedReuse" -> AuthType.MigratedReuse + "Recovered" -> AuthType.Recovered + else -> throw AssertionError("Unknown AuthType: $simpleClassName") + } + val config = DeviceConfig("test name", DeviceType.TABLET, setOf(DeviceCapability.SEND_TAB)) + AuthType::class.sealedSubclasses.map { initAuthType(it.simpleName!!) }.forEach { + constellation.finalizeDevice(it, config) + when (expectedFinalizeAction(it)) { + FxaDeviceConstellation.DeviceFinalizeAction.Initialize -> { + verify(account).initializeDevice("test name", RustDeviceType.TABLET, setOf(mozilla.appservices.fxaclient.DeviceCapability.SEND_TAB)) + } + FxaDeviceConstellation.DeviceFinalizeAction.EnsureCapabilities -> { + verify(account).ensureCapabilities(setOf(mozilla.appservices.fxaclient.DeviceCapability.SEND_TAB)) + } + FxaDeviceConstellation.DeviceFinalizeAction.None -> { + verifyNoInteractions(account) + } + } + reset(account) + } + } + + @Test + @ExperimentalCoroutinesApi + fun `updating device name`() = runTestOnMain { + val currentDevice = testDevice("currentTestDevice", true) + `when`(account.getDevices()).thenReturn(arrayOf(currentDevice)) + + // Can't update cached value in an empty cache + try { + constellation.setDeviceName("new name", testContext) + fail() + } catch (e: IllegalStateException) {} + + val cache = FxaDeviceSettingsCache(testContext) + cache.setToCache(DeviceSettings("someId", "test name", RustDeviceType.MOBILE)) + + // No device state observer. + assertTrue(constellation.setDeviceName("new name", testContext)) + verify(account, times(2)).setDeviceDisplayName("new name") + + assertEquals(DeviceSettings("someId", "new name", RustDeviceType.MOBILE), cache.getCached()) + + // Set up the observer... + val observer = object : DeviceConstellationObserver { + var state: ConstellationState? = null + + override fun onDevicesUpdate(constellation: ConstellationState) { + state = constellation + } + } + constellation.registerDeviceObserver(observer, startedLifecycleOwner(), false) + + assertTrue(constellation.setDeviceName("another name", testContext)) + verify(account).setDeviceDisplayName("another name") + + assertEquals(DeviceSettings("someId", "another name", RustDeviceType.MOBILE), cache.getCached()) + + // Since we're faking the data, here we're just testing that observer is notified with the + // up-to-date constellation. + assertEquals(observer.state!!.currentDevice!!.displayName, "testName") + } + + @Test + @ExperimentalCoroutinesApi + fun `set device push subscription`() = runTestOnMain { + val subscription = DevicePushSubscription("http://endpoint.com", "pk", "auth key") + constellation.setDevicePushSubscription(subscription) + + verify(account).setDevicePushSubscription("http://endpoint.com", "pk", "auth key") + } + + @Test + @ExperimentalCoroutinesApi + fun `process raw device command`() = runTestOnMain { + // No commands, no observer. + `when`(account.handlePushMessage("raw events payload")).thenReturn(mozilla.appservices.fxaclient.AccountEvent.Unknown) + assertTrue(constellation.processRawEvent("raw events payload")) + + // No commands, with observer. + val eventsObserver = object : AccountEventsObserver { + var latestEvents: List<AccountEvent>? = null + + override fun onEvents(events: List<AccountEvent>) { + latestEvents = events + } + } + + // No commands, with an observer. + constellation.register(eventsObserver) + assertTrue(constellation.processRawEvent("raw events payload")) + assertEquals(listOf(AccountEvent.Unknown), eventsObserver.latestEvents) + + // Some commands, with an observer. More detailed command handling tests below. + val testDevice1 = testDevice("test1", false) + val testTab1 = TabHistoryEntry("Hello", "http://world.com/1") + `when`(account.handlePushMessage("raw events payload")).thenReturn( + ASAccountEvent.CommandReceived( + command = IncomingDeviceCommand.TabReceived(testDevice1, SendTabPayload(listOf(testTab1), "flowid", "streamid")), + ), + ) + + `when`(account.pollDeviceCommands()).thenReturn( + arrayOf( + IncomingDeviceCommand.TabReceived(testDevice1, SendTabPayload(listOf(testTab1), "flowid", "streamid")), + ), + ) + assertTrue(constellation.processRawEvent("raw events payload")) + verify(account).pollDeviceCommands() + + val events = eventsObserver.latestEvents!! + val command = (events[0] as AccountEvent.DeviceCommandIncoming).command + assertEquals(testDevice1.into(), (command as DeviceCommandIncoming.TabReceived).from) + assertEquals(listOf(testTab1.into()), command.entries) + } + + @Test + fun `send command to device`() = runTestOnMain { + `when`(account.gatherTelemetry()).thenReturn("{}") + assertTrue( + constellation.sendCommandToDevice( + "targetID", + DeviceCommandOutgoing.SendTab("Mozilla", "https://www.mozilla.org"), + ), + ) + + verify(account).sendSingleTab("targetID", "Mozilla", "https://www.mozilla.org") + } + + @Test + fun `send command to device will report exceptions`() = runTestOnMain { + val exception = FxaException.Other("") + val exceptionCaptor = argumentCaptor<SendCommandException>() + doAnswer { throw exception }.`when`(account).sendSingleTab(any(), any(), any()) + + val success = constellation.sendCommandToDevice( + "targetID", + DeviceCommandOutgoing.SendTab("Mozilla", "https://www.mozilla.org"), + ) + + assertFalse(success) + verify(constellation.crashReporter!!).submitCaughtException(exceptionCaptor.capture()) + assertSame(exception, exceptionCaptor.value.cause) + } + + @Test + fun `send command to device won't report network exceptions`() = runTestOnMain { + val exception = FxaException.Network("timeout!") + doAnswer { throw exception }.`when`(account).sendSingleTab(any(), any(), any()) + + val success = constellation.sendCommandToDevice( + "targetID", + DeviceCommandOutgoing.SendTab("Mozilla", "https://www.mozilla.org"), + ) + + assertFalse(success) + verify(constellation.crashReporter!!, never()).submitCaughtException(any()) + Unit + } + + @Test + @ExperimentalCoroutinesApi + fun `refreshing constellation`() = runTestOnMain { + // No devices, no observers. + `when`(account.getDevices()).thenReturn(emptyArray()) + + constellation.refreshDevices() + + val observer = object : DeviceConstellationObserver { + var state: ConstellationState? = null + + override fun onDevicesUpdate(constellation: ConstellationState) { + state = constellation + } + } + constellation.registerDeviceObserver(observer, startedLifecycleOwner(), false) + + // No devices, with an observer. + constellation.refreshDevices() + assertEquals(ConstellationState(null, listOf()), observer.state) + + val testDevice1 = testDevice("test1", false) + val testDevice2 = testDevice("test2", false) + val currentDevice = testDevice("currentTestDevice", true) + + // Single device, no current device. + `when`(account.getDevices()).thenReturn(arrayOf(testDevice1)) + constellation.refreshDevices() + + assertEquals(ConstellationState(null, listOf(testDevice1.into())), observer.state) + assertEquals(ConstellationState(null, listOf(testDevice1.into())), constellation.state()) + + // Current device, no other devices. + `when`(account.getDevices()).thenReturn(arrayOf(currentDevice)) + constellation.refreshDevices() + assertEquals(ConstellationState(currentDevice.into(), listOf()), observer.state) + assertEquals(ConstellationState(currentDevice.into(), listOf()), constellation.state()) + + // Current device with other devices. + `when`(account.getDevices()).thenReturn( + arrayOf( + currentDevice, + testDevice1, + testDevice2, + ), + ) + constellation.refreshDevices() + + assertEquals(ConstellationState(currentDevice.into(), listOf(testDevice1.into(), testDevice2.into())), observer.state) + assertEquals(ConstellationState(currentDevice.into(), listOf(testDevice1.into(), testDevice2.into())), constellation.state()) + + // Current device with expired subscription. + val currentDeviceExpired = testDevice("currentExpired", true, expired = true) + `when`(account.getDevices()).thenReturn( + arrayOf( + currentDeviceExpired, + testDevice2, + ), + ) + + `when`(account.pollDeviceCommands()).thenReturn(emptyArray()) + `when`(account.gatherTelemetry()).thenReturn("{}") + + constellation.refreshDevices() + + verify(account, times(1)).pollDeviceCommands() + + assertEquals(ConstellationState(currentDeviceExpired.into(), listOf(testDevice2.into())), observer.state) + assertEquals(ConstellationState(currentDeviceExpired.into(), listOf(testDevice2.into())), constellation.state()) + + // Current device with no subscription. + val currentDeviceNoSub = testDevice("currentNoSub", true, expired = false, subscribed = false) + + `when`(account.getDevices()).thenReturn( + arrayOf( + currentDeviceNoSub, + testDevice2, + ), + ) + + `when`(account.pollDeviceCommands()).thenReturn(emptyArray()) + `when`(account.gatherTelemetry()).thenReturn("{}") + + constellation.refreshDevices() + + verify(account, times(2)).pollDeviceCommands() + assertEquals(ConstellationState(currentDeviceNoSub.into(), listOf(testDevice2.into())), constellation.state()) + } + + @Test + @ExperimentalCoroutinesApi + fun `polling for commands triggers observers`() = runTestOnMain { + // No commands, no observers. + `when`(account.gatherTelemetry()).thenReturn("{}") + `when`(account.pollDeviceCommands()).thenReturn(emptyArray()) + assertTrue(constellation.pollForCommands()) + + val eventsObserver = object : AccountEventsObserver { + var latestEvents: List<AccountEvent>? = null + + override fun onEvents(events: List<AccountEvent>) { + latestEvents = events + } + } + + // No commands, with an observer. + constellation.register(eventsObserver) + assertTrue(constellation.pollForCommands()) + assertEquals(listOf<AccountEvent>(), eventsObserver.latestEvents) + + // Some commands. + `when`(account.pollDeviceCommands()).thenReturn( + arrayOf( + IncomingDeviceCommand.TabReceived(null, SendTabPayload(emptyList(), "", "")), + ), + ) + assertTrue(constellation.pollForCommands()) + + var command = (eventsObserver.latestEvents!![0] as AccountEvent.DeviceCommandIncoming).command + assertEquals(null, (command as DeviceCommandIncoming.TabReceived).from) + assertEquals(listOf<TabData>(), command.entries) + + val testDevice1 = testDevice("test1", false) + val testDevice2 = testDevice("test2", false) + val testTab1 = TabHistoryEntry("Hello", "http://world.com/1") + val testTab2 = TabHistoryEntry("Hello", "http://world.com/2") + val testTab3 = TabHistoryEntry("Hello", "http://world.com/3") + + // Zero tabs from a single device. + `when`(account.pollDeviceCommands()).thenReturn( + arrayOf( + IncomingDeviceCommand.TabReceived(testDevice1, SendTabPayload(emptyList(), "", "")), + ), + ) + assertTrue(constellation.pollForCommands()) + + Assert.assertNotNull(eventsObserver.latestEvents) + assertEquals(1, eventsObserver.latestEvents!!.size) + command = (eventsObserver.latestEvents!![0] as AccountEvent.DeviceCommandIncoming).command + assertEquals(testDevice1.into(), (command as DeviceCommandIncoming.TabReceived).from) + assertEquals(listOf<TabData>(), command.entries) + + // Single tab from a single device. + `when`(account.pollDeviceCommands()).thenReturn( + arrayOf( + IncomingDeviceCommand.TabReceived(testDevice2, SendTabPayload(listOf(testTab1), "", "")), + ), + ) + assertTrue(constellation.pollForCommands()) + + command = (eventsObserver.latestEvents!![0] as AccountEvent.DeviceCommandIncoming).command + assertEquals(testDevice2.into(), (command as DeviceCommandIncoming.TabReceived).from) + assertEquals(listOf(testTab1.into()), command.entries) + + // Multiple tabs from a single device. + `when`(account.pollDeviceCommands()).thenReturn( + arrayOf( + IncomingDeviceCommand.TabReceived(testDevice2, SendTabPayload(listOf(testTab1, testTab3), "", "")), + ), + ) + assertTrue(constellation.pollForCommands()) + + command = (eventsObserver.latestEvents!![0] as AccountEvent.DeviceCommandIncoming).command + assertEquals(testDevice2.into(), (command as DeviceCommandIncoming.TabReceived).from) + assertEquals(listOf(testTab1.into(), testTab3.into()), command.entries) + + // Multiple tabs received from multiple devices. + `when`(account.pollDeviceCommands()).thenReturn( + arrayOf( + IncomingDeviceCommand.TabReceived(testDevice2, SendTabPayload(listOf(testTab1, testTab2), "", "")), + IncomingDeviceCommand.TabReceived(testDevice1, SendTabPayload(listOf(testTab3), "", "")), + ), + ) + assertTrue(constellation.pollForCommands()) + + command = (eventsObserver.latestEvents!![0] as AccountEvent.DeviceCommandIncoming).command + assertEquals(testDevice2.into(), (command as DeviceCommandIncoming.TabReceived).from) + assertEquals(listOf(testTab1.into(), testTab2.into()), command.entries) + command = (eventsObserver.latestEvents!![1] as AccountEvent.DeviceCommandIncoming).command + assertEquals(testDevice1.into(), (command as DeviceCommandIncoming.TabReceived).from) + assertEquals(listOf(testTab3.into()), command.entries) + + // TODO FirefoxAccount needs @Throws annotations for these tests to actually work. + // Failure to poll for commands. Panics are re-thrown. +// `when`(account.pollDeviceCommands()).thenThrow(FxaPanicException("Don't panic!")) +// try { +// runBlocking(coroutinesTestRule.testDispatcher) { +// constellation.refreshAsync() +// } +// fail() +// } catch (e: FxaPanicException) {} +// +// // Network exception are handled. +// `when`(account.pollDeviceCommands()).thenThrow(FxaNetworkException("four oh four")) +// runBlocking(coroutinesTestRule.testDispatcher) { +// Assert.assertFalse(constellation.refreshAsync()) +// } +// // Unspecified exception are handled. +// `when`(account.pollDeviceCommands()).thenThrow(FxaUnspecifiedException("hmmm...")) +// runBlocking(coroutinesTestRule.testDispatcher) { +// Assert.assertFalse(constellation.refreshAsync()) +// } +// // Unauthorized exception are handled. +// val authErrorObserver = object : AuthErrorObserver { +// var latestException: AuthException? = null +// +// override fun onAuthErrorAsync(e: AuthException): Deferred<Unit> { +// latestException = e +// val r = CompletableDeferred<Unit>() +// r.complete(Unit) +// return r +// } +// } +// authErrorRegistry.register(authErrorObserver) +// +// val authException = FxaUnauthorizedException("oh no you didn't!") +// `when`(account.pollDeviceCommands()).thenThrow(authException) +// runBlocking(coroutinesTestRule.testDispatcher) { +// Assert.assertFalse(constellation.refreshAsync()) +// } +// assertEquals(authErrorObserver.latestException!!.cause, authException) + } + + private fun testDevice(id: String, current: Boolean, expired: Boolean = false, subscribed: Boolean = true): NativeDevice { + return NativeDevice( + id = id, + displayName = "testName", + deviceType = RustDeviceType.MOBILE, + isCurrentDevice = current, + lastAccessTime = 123L, + capabilities = listOf(), + pushEndpointExpired = expired, + pushSubscription = if (subscribed) NativeDevicePushSubscription("http://endpoint.com", "pk", "auth key") else null, + ) + } + + private fun startedLifecycleOwner(): LifecycleOwner { + val lifecycleOwner = mock<LifecycleOwner>() + val lifecycle = mock<Lifecycle>() + `when`(lifecycle.currentState).thenReturn(Lifecycle.State.STARTED) + `when`(lifecycleOwner.lifecycle).thenReturn(lifecycle) + return lifecycleOwner + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaDeviceSettingsCacheTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaDeviceSettingsCacheTest.kt new file mode 100644 index 0000000000..5bde0e0281 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/FxaDeviceSettingsCacheTest.kt @@ -0,0 +1,69 @@ +/* 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.fxa + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.appservices.sync15.DeviceType +import mozilla.appservices.syncmanager.DeviceSettings +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.IllegalStateException + +@RunWith(AndroidJUnit4::class) +class FxaDeviceSettingsCacheTest { + @Test + fun `fxa device settings cache basics`() { + val cache = FxaDeviceSettingsCache(testContext) + assertNull(cache.getCached()) + + try { + cache.updateCachedName("new name") + fail() + } catch (e: IllegalStateException) {} + + cache.setToCache(DeviceSettings("some id", "some name", DeviceType.VR)) + assertEquals( + DeviceSettings("some id", "some name", DeviceType.VR), + cache.getCached(), + ) + + cache.updateCachedName("new name") + assertEquals( + DeviceSettings("some id", "new name", DeviceType.VR), + cache.getCached(), + ) + + cache.clear() + assertNull(cache.getCached()) + + cache.setToCache(DeviceSettings("some id", "mobile", DeviceType.MOBILE)) + assertEquals( + DeviceSettings("some id", "mobile", DeviceType.MOBILE), + cache.getCached(), + ) + + cache.setToCache(DeviceSettings("some id", "some tv", DeviceType.TV)) + assertEquals( + DeviceSettings("some id", "some tv", DeviceType.TV), + cache.getCached(), + ) + + cache.setToCache(DeviceSettings("some id", "some tablet", DeviceType.TABLET)) + assertEquals( + DeviceSettings("some id", "some tablet", DeviceType.TABLET), + cache.getCached(), + ) + + cache.setToCache(DeviceSettings("some id", "some desktop", DeviceType.DESKTOP)) + assertEquals( + DeviceSettings("some id", "some desktop", DeviceType.DESKTOP), + cache.getCached(), + ) + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/SyncAuthInfoCacheTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/SyncAuthInfoCacheTest.kt new file mode 100644 index 0000000000..cb3d1a27a7 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/SyncAuthInfoCacheTest.kt @@ -0,0 +1,51 @@ +/* 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.fxa + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.concept.sync.SyncAuthInfo +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SyncAuthInfoCacheTest { + @Test + fun `public api`() { + val cache = SyncAuthInfoCache(testContext) + assertNull(cache.getCached()) + + val authInfo = SyncAuthInfo( + kid = "testKid", + fxaAccessToken = "fxaAccess", + // expires in the future (in seconds) + fxaAccessTokenExpiresAt = (System.currentTimeMillis() / 1000L) + 60, + syncKey = "long secret key", + tokenServerUrl = "http://www.token.server/url", + ) + + cache.setToCache(authInfo) + + assertEquals(authInfo, cache.getCached()) + assertFalse(cache.expired()) + + val authInfo2 = authInfo.copy( + // expires in the past (in seconds) + fxaAccessTokenExpiresAt = (System.currentTimeMillis() / 1000L) - 60, + ) + + cache.setToCache(authInfo2) + assertEquals(authInfo2, cache.getCached()) + assertTrue(cache.expired()) + + cache.clear() + + assertNull(cache.getCached()) + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/UtilsKtTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/UtilsKtTest.kt new file mode 100644 index 0000000000..a3515c1299 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/UtilsKtTest.kt @@ -0,0 +1,391 @@ +/* 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.fxa + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.concept.sync.ServiceResult +import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.service.fxa.manager.GlobalAccountManager +import mozilla.components.support.test.eq +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.Mockito.reset +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions + +@ExperimentalCoroutinesApi // for runTest +class UtilsKtTest { + @Test + fun `handleFxaExceptions form 1 returns correct data back`() = runTest { + assertEquals( + 1, + handleFxaExceptions( + mock(), + "test op", + { + 1 + }, + { fail() }, + { fail() }, + ), + ) + + assertEquals( + "Hello", + handleFxaExceptions( + mock(), + "test op", + { + "Hello" + }, + { fail() }, + { fail() }, + ), + ) + } + + @Test + fun `handleFxaExceptions form 1 does not swallow non-panics`() = runTest { + val accountManager: FxaAccountManager = mock() + GlobalAccountManager.setInstance(accountManager) + + // Network. + assertEquals( + "pass!", + handleFxaExceptions( + mock(), + "test op", + { + throw FxaNetworkException("oops") + }, + { "fail" }, + { error -> + assertEquals("oops", error.message) + assertTrue(error is FxaNetworkException) + "pass!" + }, + ), + ) + + verifyNoInteractions(accountManager) + + assertEquals( + "pass!", + handleFxaExceptions( + mock(), + "test op", + { + throw FxaUnauthorizedException("auth!") + }, + { + "pass!" + }, + { + fail() + }, + ), + ) + + verify(accountManager).encounteredAuthError(eq("test op"), anyInt()) + + reset(accountManager) + assertEquals( + "pass!", + handleFxaExceptions( + mock(), + "test op", + { + throw FxaUnspecifiedException("dunno") + }, + { "fail" }, + { error -> + assertEquals("dunno", error.message) + assertTrue(error is FxaUnspecifiedException) + "pass!" + }, + ), + ) + verifyNoInteractions(accountManager) + } + + @Test(expected = IllegalStateException::class) + fun `handleFxaExceptions form 1 re-throws non-fxa exceptions`() = runTest { + handleFxaExceptions( + mock(), + "test op", + { + throw IllegalStateException("bad state") + }, + { fail() }, + { fail() }, + ) + } + + @Test(expected = FxaPanicException::class) + fun `handleFxaExceptions form 1 re-throws fxa panic exceptions`() = runTest { + handleFxaExceptions( + mock(), + "test op", + { + throw FxaPanicException("don't panic!") + }, + { fail() }, + { fail() }, + ) + } + + @Test + fun `handleFxaExceptions form 2 works`() = runTest { + val accountManager: FxaAccountManager = mock() + GlobalAccountManager.setInstance(accountManager) + + assertTrue( + handleFxaExceptions(mock(), "test op") { + Unit + }, + ) + + assertFalse( + handleFxaExceptions(mock(), "test op") { + throw FxaUnspecifiedException("dunno") + }, + ) + + verifyNoInteractions(accountManager) + + assertFalse( + handleFxaExceptions(mock(), "test op") { + throw FxaUnauthorizedException("401") + }, + ) + + verify(accountManager).encounteredAuthError("test op") + + reset(accountManager) + + assertFalse( + handleFxaExceptions(mock(), "test op") { + throw FxaNetworkException("dunno") + }, + ) + + verifyNoInteractions(accountManager) + } + + @Test(expected = IllegalStateException::class) + fun `handleFxaExceptions form 2 re-throws non-fxa exceptions`() = runTest { + val accountManager: FxaAccountManager = mock() + GlobalAccountManager.setInstance(accountManager) + + handleFxaExceptions(mock(), "test op") { + throw IllegalStateException("bad state") + } + verifyNoInteractions(accountManager) + } + + @Test(expected = FxaPanicException::class) + fun `handleFxaExceptions form 2 re-throws fxa panic exceptions`() = runTest { + val accountManager: FxaAccountManager = mock() + GlobalAccountManager.setInstance(accountManager) + + handleFxaExceptions(mock(), "test op") { + throw FxaPanicException("dunno") + } + + verifyNoInteractions(accountManager) + } + + @Test + fun `handleFxaExceptions form 3 works`() = runTest { + val accountManager: FxaAccountManager = mock() + GlobalAccountManager.setInstance(accountManager) + + assertEquals( + 1, + handleFxaExceptions(mock(), "test op", { 2 }) { + 1 + }, + ) + + assertEquals( + 0, + handleFxaExceptions(mock(), "test op", { 0 }) { + throw FxaUnspecifiedException("dunno") + }, + ) + + verifyNoInteractions(accountManager) + + assertEquals( + -1, + handleFxaExceptions(mock(), "test op", { -1 }) { + throw FxaUnauthorizedException("401") + }, + ) + + verify(accountManager).encounteredAuthError(eq("test op"), anyInt()) + + reset(accountManager) + + assertEquals( + "bad", + handleFxaExceptions(mock(), "test op", { "bad" }) { + throw FxaNetworkException("dunno") + }, + ) + + verifyNoInteractions(accountManager) + } + + @Test(expected = IllegalStateException::class) + fun `handleFxaExceptions form 3 re-throws non-fxa exceptions`() = runTest { + val accountManager: FxaAccountManager = mock() + GlobalAccountManager.setInstance(accountManager) + + handleFxaExceptions(mock(), "test op", { "nope" }) { + throw IllegalStateException("bad state") + } + verifyNoInteractions(accountManager) + } + + @Test(expected = FxaPanicException::class) + fun `handleFxaExceptions form 3 re-throws fxa panic exceptions`() = runTest { + val accountManager: FxaAccountManager = mock() + GlobalAccountManager.setInstance(accountManager) + + handleFxaExceptions(mock(), "test op", { "nope" }) { + throw FxaPanicException("dunno") + } + verifyNoInteractions(accountManager) + } + + @Test + fun `withRetries immediate success`() = runTest { + when (val res = withRetries(mock(), 3) { true }) { + is Result.Success -> assertTrue(res.value) + is Result.Failure -> fail() + } + when (val res = withRetries(mock(), 3) { "hello!" }) { + is Result.Success -> assertEquals("hello!", res.value) + is Result.Failure -> fail() + } + val eventual = SucceedOn(2, 42) + when (val res = withRetries(mock(), 3) { eventual.nullFailure() }) { + is Result.Success -> assertEquals(42, res.value) + is Result.Failure -> fail() + } + } + + @Test + fun `withRetries immediate failure`() = runTest { + when (withRetries(mock(), 3) { false }) { + is Result.Success -> fail() + is Result.Failure -> {} + } + when (withRetries(mock(), 3) { null }) { + is Result.Success -> fail() + is Result.Failure -> {} + } + } + + @Test + fun `withRetries eventual success`() = runTest { + val eventual = SucceedOn(2, true) + when (val res = withRetries(mock(), 5) { eventual.nullFailure() }) { + is Result.Success -> { + assertTrue(res.value!!) + assertEquals(2, eventual.attempts) + } + is Result.Failure -> fail() + } + val eventual2 = SucceedOn(2, "world!") + when (val res = withRetries(mock(), 3) { eventual2.nullFailure() }) { + is Result.Success -> { + assertEquals("world!", res.value) + assertEquals(2, eventual2.attempts) + } + is Result.Failure -> fail() + } + } + + @Test + fun `withRetries eventual failure`() = runTest { + val eventual = SucceedOn(6, true) + when (withRetries(mock(), 5) { eventual.nullFailure() }) { + is Result.Success -> fail() + is Result.Failure -> { + assertEquals(5, eventual.attempts) + } + } + val eventual2 = SucceedOn(15, "hello!") + when (withRetries(mock(), 3) { eventual2.nullFailure() }) { + is Result.Success -> fail() + is Result.Failure -> { + assertEquals(3, eventual2.attempts) + } + } + } + + @Test + fun `withServiceRetries immediate success`() = runTest { + when (withServiceRetries(mock(), 3, suspend { ServiceResult.Ok })) { + is ServiceResult.Ok -> {} + else -> fail() + } + } + + @Test + fun `withServiceRetries generic failure keeps retrying`() = runTest { + // keeps retrying on generic error + val eventual = SucceedOn(0, ServiceResult.Ok, ServiceResult.OtherError) + when (withServiceRetries(mock(), 3) { eventual.reifiedFailure() }) { + is ServiceResult.Ok -> fail() + else -> { + assertEquals(3, eventual.attempts) + } + } + } + + @Test + fun `withServiceRetries auth failure short circuit`() = runTest { + // keeps retrying on generic error + val eventual = SucceedOn(0, ServiceResult.Ok, ServiceResult.AuthError) + when (withServiceRetries(mock(), 3) { eventual.reifiedFailure() }) { + is ServiceResult.Ok -> fail() + else -> { + assertEquals(1, eventual.attempts) + } + } + } + + @Test + fun `withServiceRetries eventual success`() = runTest { + val eventual = SucceedOn(3, ServiceResult.Ok, ServiceResult.OtherError) + when (withServiceRetries(mock(), 5) { eventual.reifiedFailure() }) { + is ServiceResult.Ok -> { + assertEquals(3, eventual.attempts) + } + else -> fail() + } + } + + private class SucceedOn<S>(private val successOn: Int, private val succeedWith: S, private val failWith: S? = null) { + var attempts = 0 + fun nullFailure(): S? { + attempts += 1 + return when { + successOn == 0 || attempts < successOn -> failWith + else -> succeedWith!! + } + } + fun reifiedFailure(): S = nullFailure()!! + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/GlobalAccountManagerTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/GlobalAccountManagerTest.kt new file mode 100644 index 0000000000..bd56bd33a8 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/GlobalAccountManagerTest.kt @@ -0,0 +1,55 @@ +/* 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.fxa.manager + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.support.test.mock +import org.junit.Test +import org.mockito.Mockito + +class GlobalAccountManagerTest { + @ExperimentalCoroutinesApi + @Test + fun `GlobalAccountManager authError processing`() = runTest { + val manager: FxaAccountManager = mock() + GlobalAccountManager.setInstance(manager) + + val testClock: GlobalAccountManager.Clock = mock() + Mockito.`when`(testClock.getTimeCheckPoint()).thenReturn(1L) + + GlobalAccountManager.authError("hello", forSync = true, clock = testClock) + Mockito.verify(manager).encounteredAuthError("hello", 1) + + // another error within a second, count goes up + Mockito.`when`(testClock.getTimeCheckPoint()).thenReturn(1000L) + GlobalAccountManager.authError("fxa oops", forSync = true, clock = testClock) + Mockito.verify(manager).encounteredAuthError("fxa oops", 2) + + // But non-sync operations don't cause another recovery + Mockito.clearInvocations(manager) + GlobalAccountManager.authError("fxa oops", forSync = false, clock = testClock) + Mockito.verifyNoInteractions(manager) + + // error five minutes later, count is reset + Mockito.`when`(testClock.getTimeCheckPoint()).thenReturn(1000L * 60 * 5) + GlobalAccountManager.authError("fxa oops 2", forSync = true, clock = testClock) + Mockito.verify(manager).encounteredAuthError("fxa oops 2", 1) + + // the count is ramped up if auth errors become frequent again + Mockito.`when`(testClock.getTimeCheckPoint()).thenReturn(1000L * 60 * 5 + 1000L) + GlobalAccountManager.authError("fxa oops 2", forSync = true, clock = testClock) + Mockito.verify(manager).encounteredAuthError("fxa oops 2", 2) + + Mockito.`when`(testClock.getTimeCheckPoint()).thenReturn(1000L * 60 * 5 + 2000L) + GlobalAccountManager.authError("fxa oops 2", forSync = true, clock = testClock) + Mockito.verify(manager).encounteredAuthError("fxa oops 2", 3) + + // ... and is reset again as errors slow down. + Mockito.`when`(testClock.getTimeCheckPoint()).thenReturn(1000L * 60 * 5 + 1000L * 60 * 15) + GlobalAccountManager.authError("profile fetch", forSync = true, clock = testClock) + Mockito.verify(manager).encounteredAuthError("profile fetch", 1) + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/StateKtTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/StateKtTest.kt new file mode 100644 index 0000000000..6323861f0e --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/StateKtTest.kt @@ -0,0 +1,134 @@ +/* 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.fxa.manager + +import mozilla.components.concept.sync.AuthType +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Test + +class StateKtTest { + private fun assertNextStateForStateEventPair(state: State, event: Event, nextState: State?) { + val expectedNextState = when (state) { + is State.Idle -> when (state.accountState) { + AccountState.NotAuthenticated -> when (event) { + Event.Account.Start -> State.Active(ProgressState.Initializing) + is Event.Account.BeginEmailFlow -> State.Active(ProgressState.BeginningAuthentication) + is Event.Account.BeginPairingFlow -> State.Active(ProgressState.BeginningAuthentication) + else -> null + } + is AccountState.Authenticating -> when (event) { + Event.Progress.CancelAuth -> State.Idle(AccountState.NotAuthenticated) + is Event.Progress.AuthData -> State.Active(ProgressState.CompletingAuthentication) + else -> null + } + AccountState.Authenticated -> when (event) { + is Event.Account.AuthenticationError -> State.Active(ProgressState.RecoveringFromAuthProblem) + Event.Account.AccessTokenKeyError -> State.Idle(AccountState.AuthenticationProblem) + Event.Account.Logout -> State.Active(ProgressState.LoggingOut) + else -> null + } + AccountState.AuthenticationProblem -> when (event) { + is Event.Account.BeginEmailFlow -> State.Active(ProgressState.BeginningAuthentication) + Event.Account.Logout -> State.Active(ProgressState.LoggingOut) + else -> null + } + } + is State.Active -> when (state.progressState) { + ProgressState.Initializing -> when (event) { + Event.Progress.AccountNotFound -> State.Idle(AccountState.NotAuthenticated) + Event.Progress.AccountRestored -> State.Active(ProgressState.CompletingAuthentication) + else -> null + } + ProgressState.BeginningAuthentication -> when (event) { + Event.Progress.FailedToBeginAuth -> State.Idle(AccountState.NotAuthenticated) + is Event.Progress.StartedOAuthFlow -> State.Idle(AccountState.Authenticating(event.oAuthUrl)) + else -> null + } + ProgressState.CompletingAuthentication -> when (event) { + Event.Progress.FailedToCompleteAuth -> State.Idle(AccountState.NotAuthenticated) + Event.Progress.FailedToCompleteAuthRestore -> State.Idle(AccountState.NotAuthenticated) + is Event.Progress.CompletedAuthentication -> State.Idle(AccountState.Authenticated) + else -> null + } + ProgressState.RecoveringFromAuthProblem -> when (event) { + Event.Progress.RecoveredFromAuthenticationProblem -> State.Idle(AccountState.Authenticated) + Event.Progress.FailedToRecoverFromAuthenticationProblem -> State.Idle(AccountState.AuthenticationProblem) + else -> null + } + ProgressState.LoggingOut -> when (event) { + Event.Progress.LoggedOut -> State.Idle(AccountState.NotAuthenticated) + else -> null + } + } + } + + assertEquals(expectedNextState, nextState) + } + + private fun instantiateAccountState(simpleName: String): AccountState { + return when (simpleName) { + "NotAuthenticated" -> AccountState.NotAuthenticated + "Authenticating" -> AccountState.Authenticating("https://example.com/oauth-start") + "Authenticated" -> AccountState.Authenticated + "AuthenticationProblem" -> AccountState.AuthenticationProblem + else -> { + throw AssertionError("Unknown AccountState: $simpleName") + } + } + } + + private fun instantiateEvent(eventClassSimpleName: String): Event { + return when (eventClassSimpleName) { + "Start" -> Event.Account.Start + "BeginPairingFlow" -> Event.Account.BeginPairingFlow("http://some.pairing.url.com", mock()) + "BeginEmailFlow" -> Event.Account.BeginEmailFlow(mock()) + "CancelAuth" -> Event.Progress.CancelAuth + "StartedOAuthFlow" -> Event.Progress.StartedOAuthFlow("https://example.com/oauth-start") + "AuthenticationError" -> Event.Account.AuthenticationError("fxa op") + "AccessTokenKeyError" -> Event.Account.AccessTokenKeyError + "Logout" -> Event.Account.Logout + "AccountNotFound" -> Event.Progress.AccountNotFound + "AccountRestored" -> Event.Progress.AccountRestored + "AuthData" -> Event.Progress.AuthData(mock()) + "LoggedOut" -> Event.Progress.LoggedOut + "FailedToRecoverFromAuthenticationProblem" -> Event.Progress.FailedToRecoverFromAuthenticationProblem + "RecoveredFromAuthenticationProblem" -> Event.Progress.RecoveredFromAuthenticationProblem + "CompletedAuthentication" -> Event.Progress.CompletedAuthentication(mock<AuthType.Existing>()) + "FailedToBeginAuth" -> Event.Progress.FailedToBeginAuth + "FailedToCompleteAuth" -> Event.Progress.FailedToCompleteAuth + "FailedToCompleteAuthRestore" -> Event.Progress.FailedToCompleteAuthRestore + else -> { + throw AssertionError("Unknown event: $eventClassSimpleName") + } + } + } + + @Test + fun `state transition matrix`() { + // We want to test every combination of state/event. Do that by iterating over entire sets. + ProgressState.values().forEach { state -> + Event.Progress::class.sealedSubclasses.map { instantiateEvent(it.simpleName!!) }.forEach { + val ss = State.Active(state) + assertNextStateForStateEventPair( + ss, + it, + ss.next(it), + ) + } + } + + AccountState::class.sealedSubclasses.map { instantiateAccountState(it.simpleName!!) }.forEach { state -> + Event.Account::class.sealedSubclasses.map { instantiateEvent(it.simpleName!!) }.forEach { + val ss = State.Idle(state) + assertNextStateForStateEventPair( + ss, + it, + ss.next(it), + ) + } + } + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/SyncEnginesStorageTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/SyncEnginesStorageTest.kt new file mode 100644 index 0000000000..e94b41769b --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/SyncEnginesStorageTest.kt @@ -0,0 +1,47 @@ +/* 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.fxa.manager + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.service.fxa.SyncEngine +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SyncEnginesStorageTest { + @Test + fun `sync engine storage basics`() { + val store = SyncEnginesStorage(testContext) + assertEquals(emptyMap<SyncEngine, Boolean>(), store.getStatus()) + + store.setStatus(SyncEngine.Bookmarks, false) + assertEquals(mapOf(SyncEngine.Bookmarks to false), store.getStatus()) + + store.setStatus(SyncEngine.Bookmarks, true) + assertEquals(mapOf(SyncEngine.Bookmarks to true), store.getStatus()) + + store.setStatus(SyncEngine.Passwords, false) + assertEquals(mapOf(SyncEngine.Bookmarks to true, SyncEngine.Passwords to false), store.getStatus()) + + store.setStatus(SyncEngine.Bookmarks, false) + assertEquals(mapOf(SyncEngine.Bookmarks to false, SyncEngine.Passwords to false), store.getStatus()) + + store.setStatus(SyncEngine.Other("test"), true) + assertEquals( + mapOf( + SyncEngine.Bookmarks to false, + SyncEngine.Passwords to false, + SyncEngine.Other("test") to true, + ), + store.getStatus(), + ) + + store.clear() + assertTrue(store.getStatus().isNullOrEmpty()) + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/ext/FxaAccountManagerKtTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/ext/FxaAccountManagerKtTest.kt new file mode 100644 index 0000000000..502f32a6db --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/manager/ext/FxaAccountManagerKtTest.kt @@ -0,0 +1,36 @@ +/* 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.fxa.manager.ext + +import mozilla.components.concept.sync.DeviceConstellation +import mozilla.components.concept.sync.OAuthAccount +import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.support.test.mock +import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +class FxaAccountManagerKtTest { + + @Test + fun `block is executed only account is available`() { + val accountManager: FxaAccountManager = mock() + val block: DeviceConstellation.() -> Unit = mock() + val account: OAuthAccount = mock() + val constellation: DeviceConstellation = mock() + + accountManager.withConstellation(block) + + verify(block, never()).invoke(constellation) + + `when`(accountManager.authenticatedAccount()).thenReturn(account) + `when`(account.deviceConstellation()).thenReturn(constellation) + + accountManager.withConstellation(block) + + verify(block).invoke(constellation) + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/store/SyncStoreSupportTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/store/SyncStoreSupportTest.kt new file mode 100644 index 0000000000..e0d90118fa --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/store/SyncStoreSupportTest.kt @@ -0,0 +1,190 @@ +/* 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.fxa.store + +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import mozilla.components.concept.sync.AuthType +import mozilla.components.concept.sync.Avatar +import mozilla.components.concept.sync.ConstellationState +import mozilla.components.concept.sync.DeviceConstellation +import mozilla.components.concept.sync.OAuthAccount +import mozilla.components.concept.sync.Profile +import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.support.test.any +import mozilla.components.support.test.coMock +import mozilla.components.support.test.eq +import mozilla.components.support.test.libstate.ext.waitUntilIdle +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.verify +import java.lang.Exception + +@OptIn(ExperimentalCoroutinesApi::class) +class SyncStoreSupportTest { + + private val accountManager = mock<FxaAccountManager>() + private val lifecycleOwner = mock<LifecycleOwner>() + private val autoPause = false + private val coroutineScope = TestScope() + + private lateinit var store: SyncStore + private lateinit var syncObserver: AccountSyncObserver + private lateinit var constellationObserver: ConstellationObserver + private lateinit var accountObserver: FxaAccountObserver + private lateinit var integration: SyncStoreSupport + + @Before + fun setup() { + Dispatchers.setMain(StandardTestDispatcher(coroutineScope.testScheduler)) + + store = SyncStore() + syncObserver = AccountSyncObserver(store) + constellationObserver = ConstellationObserver(store) + accountObserver = FxaAccountObserver( + store = store, + deviceConstellationObserver = constellationObserver, + lifecycleOwner = lifecycleOwner, + autoPause = autoPause, + coroutineScope = coroutineScope, + ) + + integration = SyncStoreSupport( + store = store, + fxaAccountManager = lazyOf(accountManager), + lifecycleOwner = lifecycleOwner, + autoPause = autoPause, + coroutineScope = coroutineScope, + ) + } + + @Test + fun `GIVEN integration WHEN initialize is called THEN observers registered`() { + integration.initialize() + + verify(accountManager).registerForSyncEvents(any(), eq(lifecycleOwner), eq(autoPause)) + verify(accountManager).register(any(), eq(lifecycleOwner), eq(autoPause)) + } + + @Test + fun `GIVEN sync observer WHEN onStarted observed THEN sync status updated`() { + syncObserver.onStarted() + + store.waitUntilIdle() + assertEquals(SyncStatus.Started, store.state.status) + } + + @Test + fun `GIVEN sync observer WHEN onIdle observed THEN sync status updated`() { + syncObserver.onIdle() + + store.waitUntilIdle() + assertEquals(SyncStatus.Idle, store.state.status) + } + + @Test + fun `GIVEN sync observer WHEN onError observed THEN sync status updated`() { + syncObserver.onError(Exception()) + + store.waitUntilIdle() + assertEquals(SyncStatus.Error, store.state.status) + } + + @Test + fun `GIVEN account observer WHEN onAuthenticated observed THEN device observer registered`() = runTest { + val constellation = mock<DeviceConstellation>() + val account = mock<OAuthAccount> { + whenever(deviceConstellation()).thenReturn(constellation) + } + + accountObserver.onAuthenticated(account, mock<AuthType.Existing>()) + runCurrent() + + verify(constellation).registerDeviceObserver(constellationObserver, lifecycleOwner, autoPause) + } + + @Test + fun `GIVEN account observer WHEN onAuthenticated observed with profile THEN account state updated`() = coroutineScope.runTest { + val profile = generateProfile() + val constellation = mock<DeviceConstellation>() + val account = coMock<OAuthAccount> { + whenever(deviceConstellation()).thenReturn(constellation) + whenever(getCurrentDeviceId()).thenReturn("id") + whenever(getSessionToken()).thenReturn("token") + whenever(getProfile()).thenReturn(profile) + } + + accountObserver.onAuthenticated(account, mock<AuthType.Existing>()) + runCurrent() + + val expected = Account( + profile.uid, + profile.email, + profile.avatar, + profile.displayName, + "id", + "token", + ) + store.waitUntilIdle() + assertEquals(expected, store.state.account) + } + + @Test + fun `GIVEN account observer WHEN onAuthenticated observed without profile THEN account not updated`() = coroutineScope.runTest { + val constellation = mock<DeviceConstellation>() + val account = coMock<OAuthAccount> { + whenever(deviceConstellation()).thenReturn(constellation) + whenever(getProfile()).thenReturn(null) + } + + accountObserver.onAuthenticated(account, mock<AuthType.Existing>()) + runCurrent() + + store.waitUntilIdle() + assertEquals(null, store.state.account) + } + + @Test + fun `GIVEN user is logged in WHEN onLoggedOut observed THEN sync status and account updated`() = coroutineScope.runTest { + val account = coMock<OAuthAccount> { + whenever(deviceConstellation()).thenReturn(mock()) + whenever(getProfile()).thenReturn(null) + } + accountObserver.onAuthenticated(account, mock<AuthType.Existing>()) + runCurrent() + + accountObserver.onLoggedOut() + runCurrent() + + store.waitUntilIdle() + assertEquals(SyncStatus.LoggedOut, store.state.status) + assertEquals(null, store.state.account) + } + + @Test + fun `GIVEN device observer WHEN onDevicesUpdate observed THEN constellation state updated`() { + val constellation = mock<ConstellationState>() + constellationObserver.onDevicesUpdate(constellation) + + store.waitUntilIdle() + assertEquals(constellation, store.state.constellationState) + } + + private fun generateProfile( + uid: String = "uid", + email: String = "email", + avatar: Avatar = Avatar("url", true), + displayName: String = "displayName", + ) = Profile(uid, email, avatar, displayName) +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/sync/TypesTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/sync/TypesTest.kt new file mode 100644 index 0000000000..16f7839759 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/sync/TypesTest.kt @@ -0,0 +1,77 @@ +/* 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.fxa.sync + +import mozilla.components.service.fxa.SyncEngine +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class TypesTest { + + @Test + fun `raw strings are correctly mapped to SyncEngine types`() { + assertEquals(SyncEngine.Tabs, "tabs".toSyncEngine()) + assertEquals(SyncEngine.History, "history".toSyncEngine()) + assertEquals(SyncEngine.Bookmarks, "bookmarks".toSyncEngine()) + assertEquals(SyncEngine.Passwords, "passwords".toSyncEngine()) + assertEquals(SyncEngine.CreditCards, "creditcards".toSyncEngine()) + assertEquals(SyncEngine.Addresses, "addresses".toSyncEngine()) + assertEquals(SyncEngine.Other("other"), "other".toSyncEngine()) + } + + @Test + fun `a list of raw strings are correctly mapped to a set of SyncEngine engines`() { + assertEquals( + setOf(SyncEngine.History), + listOf("history").toSyncEngines(), + ) + + assertEquals( + setOf(SyncEngine.Bookmarks, SyncEngine.History), + listOf("history", "bookmarks").toSyncEngines(), + ) + + assertEquals( + setOf(SyncEngine.History, SyncEngine.CreditCards), + listOf("history", "creditcards").toSyncEngines(), + ) + + assertEquals( + setOf(SyncEngine.Other("other"), SyncEngine.CreditCards), + listOf("other", "creditcards").toSyncEngines(), + ) + + assertEquals( + setOf(SyncEngine.Bookmarks, SyncEngine.History), + listOf("history", "bookmarks", "bookmarks", "history").toSyncEngines(), + ) + } + + @Test + fun `raw strings are correctly mapped to SyncReason types`() { + assertEquals(SyncReason.Startup, "startup".toSyncReason()) + assertEquals(SyncReason.FirstSync, "first_sync".toSyncReason()) + assertEquals(SyncReason.Scheduled, "scheduled".toSyncReason()) + assertEquals(SyncReason.User, "user".toSyncReason()) + assertEquals(SyncReason.EngineChange, "engine_change".toSyncReason()) + } + + @Test + fun `SyncReason types are correctly mapped to strings`() { + assertEquals("startup", SyncReason.Startup.asString()) + assertEquals("first_sync", SyncReason.FirstSync.asString()) + assertEquals("scheduled", SyncReason.Scheduled.asString()) + assertEquals("user", SyncReason.User.asString()) + assertEquals("engine_change", SyncReason.EngineChange.asString()) + } + + @Test + fun `invalid sync reason raw strings throw IllegalStateException when mapped`() { + assertThrows("Invalid SyncReason: some_reason", IllegalStateException::class.java) { + "some_reason".toSyncReason() + } + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/sync/WorkManagerSyncManagerTest.kt b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/sync/WorkManagerSyncManagerTest.kt new file mode 100644 index 0000000000..77dd6dd633 --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/java/mozilla/components/service/fxa/sync/WorkManagerSyncManagerTest.kt @@ -0,0 +1,97 @@ +/* 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.fxa.sync + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.WorkerParameters +import androidx.work.impl.utils.taskexecutor.TaskExecutor +import mozilla.components.service.fxa.sync.WorkManagerSyncWorker.Companion.SYNC_STAGGER_BUFFER_MS +import mozilla.components.service.fxa.sync.WorkManagerSyncWorker.Companion.engineSyncTimestamp +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.`when` + +@RunWith(AndroidJUnit4::class) +class WorkManagerSyncManagerTest { + private lateinit var mockParam: WorkerParameters + private lateinit var mockTags: Set<String> + private lateinit var mockTaskExecutor: TaskExecutor + + @Before + fun setUp() { + mockParam = mock() + mockTags = mock() + mockTaskExecutor = mock() + `when`(mockParam.taskExecutor).thenReturn(mockTaskExecutor) + `when`(mockTaskExecutor.serialTaskExecutor).thenReturn(mock()) + `when`(mockParam.tags).thenReturn(mockTags) + } + + @Test + fun `sync state access`() { + assertNull(getSyncState(testContext)) + assertEquals(0L, getLastSynced(testContext)) + + // 'clear' doesn't blow up for empty state + clearSyncState(testContext) + // ... and doesn't affect anything, either + assertNull(getSyncState(testContext)) + assertEquals(0L, getLastSynced(testContext)) + + setSyncState(testContext, "some state") + assertEquals("some state", getSyncState(testContext)) + + setLastSynced(testContext, 123L) + assertEquals(123L, getLastSynced(testContext)) + + clearSyncState(testContext) + assertNull(getSyncState(testContext)) + assertEquals(0L, getLastSynced(testContext)) + } + + @Test + fun `GIVEN work is not set to be debounced THEN it is not considered to be synced within the buffer`() { + `when`(mockTags.contains(SyncWorkerTag.Debounce.name)).thenReturn(false) + + engineSyncTimestamp["test"] = System.currentTimeMillis() - SYNC_STAGGER_BUFFER_MS - 100L + engineSyncTimestamp["test2"] = System.currentTimeMillis() + + val workerManagerSyncWorker = WorkManagerSyncWorker(testContext, mockParam) + + assertFalse(workerManagerSyncWorker.isDebounced()) + assertFalse(workerManagerSyncWorker.lastSyncedWithinStaggerBuffer("test")) + assertFalse(workerManagerSyncWorker.lastSyncedWithinStaggerBuffer("test2")) + } + + @Test + fun `GIVEN work is set to be debounced THEN last synced timestamp is compared to buffer`() { + `when`(mockTags.contains(SyncWorkerTag.Debounce.name)).thenReturn(true) + + engineSyncTimestamp["test"] = System.currentTimeMillis() - SYNC_STAGGER_BUFFER_MS - 100L + engineSyncTimestamp["test2"] = System.currentTimeMillis() + + val workerManagerSyncWorker = WorkManagerSyncWorker(testContext, mockParam) + + assert(workerManagerSyncWorker.isDebounced()) + assertFalse(workerManagerSyncWorker.lastSyncedWithinStaggerBuffer("test")) + assert(workerManagerSyncWorker.lastSyncedWithinStaggerBuffer("test2")) + } + + @Test + fun `GIVEN work is set to be debounced WHEN there is not a saved time stamp THEN work will not be debounced`() { + `when`(mockTags.contains(SyncWorkerTag.Debounce.name)).thenReturn(true) + + val workerManagerSyncWorker = WorkManagerSyncWorker(testContext, mockParam) + + assert(workerManagerSyncWorker.isDebounced()) + assertFalse(workerManagerSyncWorker.lastSyncedWithinStaggerBuffer("test")) + } +} diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/service/firefox-accounts/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/firefox-accounts/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/mobile/android/android-components/components/service/firefox-accounts/src/test/resources/robolectric.properties b/mobile/android/android-components/components/service/firefox-accounts/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/service/firefox-accounts/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/mobile/android/android-components/components/service/glean/README.md b/mobile/android/android-components/components/service/glean/README.md new file mode 100644 index 0000000000..c08c0d17f1 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/README.md @@ -0,0 +1,21 @@ +# [Android Components](../../../README.md) > Service > Glean + +A client-side telemetry SDK for collecting metrics and sending them to Mozilla's telemetry service. + +Visit the [complete Glean SDK documentation](https://mozilla.github.io/glean/). + +## Contact + +To contact us you can: +- Find us in the [#glean channel on chat.mozilla.org](https://chat.mozilla.org/#/room/#glean:mozilla.org). +* To report issues or request changes, file a bug in [Bugzilla in Data Platform & Tools :: Glean: SDK][newbugzilla]. +- Send an email to *glean-team@mozilla.com*. +* The Glean Android team is: *:dexter*, *:travis*, *:mdroettboom*, *:janerik*, *:brizental*. + +## 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/ + +[newbugzilla]: https://bugzilla.mozilla.org/enter_bug.cgi?product=Data+Platform+and+Tools&component=Glean%3A+SDK&priority=P3&status_whiteboard=%5Btelemetry%3Aglean-rs%3Am%3F%5D diff --git a/mobile/android/android-components/components/service/glean/build.gradle b/mobile/android/android-components/components/service/glean/build.gradle new file mode 100644 index 0000000000..aaed4bac50 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/build.gradle @@ -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/. */ + +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.glean' +} + +// Define library names and version constants. +String GLEAN_LIBRARY = "org.mozilla.telemetry:glean:${Versions.mozilla_glean}" +String GLEAN_LIBRARY_FORUNITTESTS = "org.mozilla.telemetry:glean-native-forUnitTests:${Versions.mozilla_glean}" + +dependencies { + implementation ComponentsDependencies.kotlin_coroutines + implementation ComponentsDependencies.androidx_work_runtime + + api GLEAN_LIBRARY + + // So consumers can set a HTTP client. + api project(':concept-fetch') + + implementation project(':support-ktx') + implementation project(':support-base') + implementation project(':support-utils') + + testImplementation ComponentsDependencies.androidx_test_core + + testImplementation ComponentsDependencies.testing_junit + testImplementation ComponentsDependencies.testing_robolectric + testImplementation ComponentsDependencies.testing_mockwebserver + testImplementation ComponentsDependencies.androidx_work_testing + + testImplementation project(':support-test') + testImplementation project(':lib-fetch-httpurlconnection') + testImplementation project(':lib-fetch-okhttp') + + testImplementation GLEAN_LIBRARY_FORUNITTESTS +} + +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/glean/gradle.properties b/mobile/android/android-components/components/service/glean/gradle.properties new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/gradle.properties diff --git a/mobile/android/android-components/components/service/glean/proguard-rules.pro b/mobile/android/android-components/components/service/glean/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/service/glean/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/glean/src/main/AndroidManifest.xml b/mobile/android/android-components/components/service/glean/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e16cda1d34 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/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/glean/src/main/java/mozilla/components/service/glean/Glean.kt b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/Glean.kt new file mode 100644 index 0000000000..232ac1b83f --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/Glean.kt @@ -0,0 +1,145 @@ +/* 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.glean + +import android.content.Context +import androidx.annotation.MainThread +import androidx.annotation.VisibleForTesting +import mozilla.components.service.glean.config.Configuration +import mozilla.components.service.glean.private.RecordedExperiment +import org.json.JSONObject +import mozilla.telemetry.glean.Glean as GleanCore + +typealias BuildInfo = mozilla.telemetry.glean.BuildInfo + +/** + * In contrast with other glean-ac classes (i.e. Configuration), we can't + * use typealias to export mozilla.telemetry.glean.Glean, as we need to provide + * a different default [Configuration]. Moreover, we can't simply delegate other + * methods or inherit, since that doesn't work for `object` in Kotlin. + */ +object Glean { + /** + * Initialize Glean. + * + * This should only be initialized once by the application, and not by + * libraries using Glean. A message is logged to error and no changes are made + * to the state if initialize is called a more than once. + * + * A LifecycleObserver will be added to send pings when the application goes + * into the background. + * + * @param applicationContext [Context] to access application features, such + * as shared preferences + * @param uploadEnabled A [Boolean] that determines the initial state of the uploader + * @param configuration A Glean [Configuration] object with global settings. + * @param buildInfo A Glean [BuildInfo] object with build-time metadata. This + * object is generated at build time by glean_parser at the import path + * ${YOUR_PACKAGE_ROOT}.GleanMetrics.GleanBuildInfo.buildInfo + */ + @MainThread + fun initialize( + applicationContext: Context, + uploadEnabled: Boolean, + configuration: Configuration, + buildInfo: BuildInfo, + ) { + GleanCore.initialize( + applicationContext = applicationContext, + uploadEnabled = uploadEnabled, + configuration = configuration.toWrappedConfiguration(), + buildInfo = buildInfo, + ) + } + + /** + * Register the pings generated from `pings.yaml` with Glean. + * + * @param pings The `Pings` object generated for your library or application + * by Glean. + */ + fun registerPings(pings: Any) { + GleanCore.registerPings(pings) + } + + /** + * Enable or disable Glean collection and upload. + * + * Metric collection is enabled by default. + * + * When disabled, metrics aren't recorded at all and no data + * is uploaded. + * + * @param enabled When true, enable metric collection. + */ + fun setUploadEnabled(enabled: Boolean) { + GleanCore.setUploadEnabled(enabled) + } + + /** + * Indicate that an experiment is running. Glean will then add an + * experiment annotation to the environment which is sent with pings. This + * information is not persisted between runs. + * + * @param experimentId The id of the active experiment (maximum + * 30 bytes) + * @param branch The experiment branch (maximum 30 bytes) + * @param extra Optional metadata to output with the ping + */ + @JvmOverloads + fun setExperimentActive( + experimentId: String, + branch: String, + extra: Map<String, String>? = null, + ) { + GleanCore.setExperimentActive( + experimentId = experimentId, + branch = branch, + extra = extra, + ) + } + + /** + * Indicate that an experiment is no longer running. + * + * @param experimentId The id of the experiment to deactivate. + */ + fun setExperimentInactive(experimentId: String) { + GleanCore.setExperimentInactive(experimentId = experimentId) + } + + /** + * Set configuration to override metrics' enabled state, typically from a remote_settings + * experiment or rollout. + * + * @param enabled Map of metrics' enabled state. + */ + fun setMetricsEnabledConfig(enabled: Map<String, Boolean>) { + GleanCore.setMetricsEnabledConfig(JSONObject(enabled).toString()) + } + + /** + * Tests whether an experiment is active, for testing purposes only. + * + * @param experimentId the id of the experiment to look for. + * @return true if the experiment is active and reported in pings, otherwise false + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + fun testIsExperimentActive(experimentId: String): Boolean { + return GleanCore.testIsExperimentActive(experimentId) + } + + /** + * Returns the stored data for the requested active experiment, for testing purposes only. + * + * @param experimentId the id of the experiment to look for. + * @return the [RecordedExperiment] for the experiment + * @throws [NullPointerException] if the requested experiment is not active or data is corrupt. + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + fun testGetExperimentData(experimentId: String): RecordedExperiment { + return GleanCore.testGetExperimentData(experimentId) + } +} diff --git a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/config/Configuration.kt b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/config/Configuration.kt new file mode 100644 index 0000000000..11911c7046 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/config/Configuration.kt @@ -0,0 +1,50 @@ +/* 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.glean.config + +import mozilla.telemetry.glean.net.PingUploader +import mozilla.telemetry.glean.config.Configuration as GleanCoreConfiguration + +/** + * The Configuration class describes how to configure the Glean. + * + * @property httpClient The HTTP client implementation to use for uploading pings. + * If you don't provide your own networking stack with an HTTP client to use, + * you can fall back to a simple implementation on top of `java.net` using + * `ConceptFetchHttpUploader(lazy { HttpURLConnectionClient() as Client })` + * @property serverEndpoint (optional) the server pings are sent to. Please note that this is + * is only meant to be changed for tests. + * @property channel (optional )the release channel the application is on, if known. This will be + * sent along with all the pings, in the `client_info` section. + * @property maxEvents (optional) the number of events to store before the events ping is sent + */ +data class Configuration @JvmOverloads constructor( + val httpClient: PingUploader, + val serverEndpoint: String = DEFAULT_TELEMETRY_ENDPOINT, + val channel: String? = null, + val maxEvents: Int? = null, + val enableEventTimestamps: Boolean = false, +) { + // The following is required to support calling our API from Java. + companion object { + const val DEFAULT_TELEMETRY_ENDPOINT = GleanCoreConfiguration.DEFAULT_TELEMETRY_ENDPOINT + } + + /** + * Convert the Android Components configuration object to the Glean SDK + * configuration object. + * + * @return a [mozilla.telemetry.glean.config.Configuration] instance. + */ + fun toWrappedConfiguration(): GleanCoreConfiguration { + return GleanCoreConfiguration( + serverEndpoint = serverEndpoint, + channel = channel, + maxEvents = maxEvents, + httpClient = httpClient, + enableEventTimestamps = enableEventTimestamps, + ) + } +} diff --git a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/net/ConceptFetchHttpUploader.kt b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/net/ConceptFetchHttpUploader.kt new file mode 100644 index 0000000000..06947e6d5b --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/net/ConceptFetchHttpUploader.kt @@ -0,0 +1,109 @@ +/* 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.glean.net + +import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.Companion.PRIVATE +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Header +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.toMutableHeaders +import mozilla.components.support.base.log.logger.Logger +import mozilla.telemetry.glean.net.HeadersList +import mozilla.telemetry.glean.net.HttpStatus +import mozilla.telemetry.glean.net.RecoverableFailure +import mozilla.telemetry.glean.net.UploadResult +import java.io.IOException +import java.util.concurrent.TimeUnit +import mozilla.telemetry.glean.net.PingUploader as CorePingUploader + +typealias PingUploader = CorePingUploader + +/** + * A simple ping Uploader, which implements a "send once" policy, never + * storing or attempting to send the ping again. This uses Android Component's + * `concept-fetch`. + * + * @param usePrivateRequest Sets the [Request.private] flag in all requests using this uploader. + */ +class ConceptFetchHttpUploader( + internal val client: Lazy<Client>, + private val usePrivateRequest: Boolean = false, +) : PingUploader { + private val logger = Logger("glean/ConceptFetchHttpUploader") + + companion object { + // The timeout, in milliseconds, to use when connecting to the server. + const val DEFAULT_CONNECTION_TIMEOUT = 10000L + + // The timeout, in milliseconds, to use when reading from the server. + const val DEFAULT_READ_TIMEOUT = 30000L + + /** + * Export a constructor that is usable from Java. + * + * This looses the lazyness of creating the `client`. + */ + @JvmStatic + fun fromClient(client: Client): ConceptFetchHttpUploader { + return ConceptFetchHttpUploader(lazy { client }) + } + } + + /** + * Synchronously upload a ping to a server. + * + * @param url the URL path to upload the data to + * @param data the serialized text data to send + * @param headers a [HeadersList] containing String to String [Pair] with + * the first entry being the header name and the second its value. + * + * @return true if the ping was correctly dealt with (sent successfully + * or faced an unrecoverable error), false if there was a recoverable + * error callers can deal with. + */ + override fun upload(url: String, data: ByteArray, headers: HeadersList): UploadResult { + val request = buildRequest(url, data, headers) + + return try { + performUpload(client.value, request) + } catch (e: IOException) { + logger.warn("IOException while uploading ping", e) + RecoverableFailure(0) + } + } + + @VisibleForTesting(otherwise = PRIVATE) + internal fun buildRequest( + url: String, + data: ByteArray, + headers: HeadersList, + ): Request { + val conceptHeaders = headers.map { (name, value) -> Header(name, value) }.toMutableHeaders() + + return Request( + url = url, + method = Request.Method.POST, + connectTimeout = Pair(DEFAULT_CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS), + readTimeout = Pair(DEFAULT_READ_TIMEOUT, TimeUnit.MILLISECONDS), + headers = conceptHeaders, + // Make sure we are not sending cookies. Unfortunately, HttpURLConnection doesn't + // offer a better API to do that, so we nuke all cookies going to our telemetry + // endpoint. + cookiePolicy = Request.CookiePolicy.OMIT, + body = Request.Body(data.inputStream()), + private = usePrivateRequest, + conservative = true, + ) + } + + @Throws(IOException::class) + internal fun performUpload(client: Client, request: Request): UploadResult { + logger.debug("Submitting ping to: ${request.url}") + client.fetch(request).use { response -> + return HttpStatus(response.status) + } + } +} diff --git a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/private/MetricAliases.kt b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/private/MetricAliases.kt new file mode 100644 index 0000000000..e6e0be9424 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/private/MetricAliases.kt @@ -0,0 +1,119 @@ +/* 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.glean.private + +import androidx.annotation.VisibleForTesting + +typealias CommonMetricData = mozilla.telemetry.glean.private.CommonMetricData +typealias EventExtras = mozilla.telemetry.glean.private.EventExtras +typealias Lifetime = mozilla.telemetry.glean.private.Lifetime +typealias NoExtras = mozilla.telemetry.glean.private.NoExtras +typealias NoReasonCodes = mozilla.telemetry.glean.private.NoReasonCodes +typealias ReasonCode = mozilla.telemetry.glean.private.ReasonCode + +typealias BooleanMetricType = mozilla.telemetry.glean.private.BooleanMetricType +typealias CounterMetricType = mozilla.telemetry.glean.private.CounterMetricType +typealias CustomDistributionMetricType = mozilla.telemetry.glean.private.CustomDistributionMetricType +typealias DatetimeMetricType = mozilla.telemetry.glean.private.DatetimeMetricType +typealias DenominatorMetricType = mozilla.telemetry.glean.private.DenominatorMetricType +typealias HistogramMetricBase = mozilla.telemetry.glean.private.HistogramBase +typealias HistogramType = mozilla.telemetry.glean.private.HistogramType +typealias LabeledMetricType<T> = mozilla.telemetry.glean.private.LabeledMetricType<T> +typealias MemoryDistributionMetricType = mozilla.telemetry.glean.private.MemoryDistributionMetricType +typealias MemoryUnit = mozilla.telemetry.glean.private.MemoryUnit +typealias NumeratorMetricType = mozilla.telemetry.glean.private.NumeratorMetricType +typealias PingType<T> = mozilla.telemetry.glean.private.PingType<T> +typealias QuantityMetricType = mozilla.telemetry.glean.private.QuantityMetricType +typealias RateMetricType = mozilla.telemetry.glean.private.RateMetricType +typealias RecordedExperiment = mozilla.telemetry.glean.private.RecordedExperiment +typealias StringListMetricType = mozilla.telemetry.glean.private.StringListMetricType +typealias StringMetricType = mozilla.telemetry.glean.private.StringMetricType +typealias TextMetricType = mozilla.telemetry.glean.private.TextMetricType +typealias TimeUnit = mozilla.telemetry.glean.private.TimeUnit +typealias TimespanMetricType = mozilla.telemetry.glean.private.TimespanMetricType +typealias TimingDistributionMetricType = mozilla.telemetry.glean.private.TimingDistributionMetricType +typealias UrlMetricType = mozilla.telemetry.glean.private.UrlMetricType +typealias UuidMetricType = mozilla.telemetry.glean.private.UuidMetricType + +// FIXME(bug 1885170): Wrap the Glean SDK `EventMetricType` to overwrite the `testGetValue` function. +/** + * This implements the developer facing API for recording events. + * + * Instances of this class type are automatically generated by the parsers at built time, + * allowing developers to record events that were previously registered in the metrics.yaml file. + * + * The Events API only exposes the [record] method, which takes care of validating the input + * data and making sure that limits are enforced. + */ +class EventMetricType<ExtraObject> internal constructor( + private var inner: mozilla.telemetry.glean.private.EventMetricType<ExtraObject>, +) where ExtraObject : EventExtras { + /** + * The public constructor used by automatically generated metrics. + */ + constructor(meta: CommonMetricData, allowedExtraKeys: List<String>) : + this(inner = mozilla.telemetry.glean.private.EventMetricType(meta, allowedExtraKeys)) + + /** + * Record an event by using the information provided by the instance of this class. + * + * @param extra The event extra properties. + * Values are converted to strings automatically + * This is used for events where additional richer context is needed. + * The maximum length for values is 100 bytes. + * + * Note: `extra` is not optional here to avoid overlapping with the above definition of `record`. + * If no `extra` data is passed the above function will be invoked correctly. + */ + fun record(extra: ExtraObject? = null) { + inner.record(extra) + } + + /** + * Returns the stored value for testing purposes only. This function will attempt to await the + * last task (if any) writing to the the metric's storage engine before returning a value. + * + * @param pingName represents the name of the ping to retrieve the metric for. + * Defaults to the first value in `sendInPings`. + * @return value of the stored events + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + @JvmOverloads + fun testGetValue(pingName: String? = null): List<mozilla.telemetry.glean.private.RecordedEvent>? { + var events = inner.testGetValue(pingName) + if (events == null) { + return events + } + + // Remove the `glean_timestamp` extra. + // This is added by Glean and does not need to be exposed to testing. + for (event in events) { + if (event.extra == null) { + continue + } + + // We know it's not null + var map = event.extra!!.toMutableMap() + map.remove("glean_timestamp") + if (map.isEmpty()) { + event.extra = null + } else { + event.extra = map + } + } + + return events + } + + /** + * Returns the number of errors recorded for the given metric. + * + * @param errorType The type of the error recorded. + * @return the number of errors recorded for the metric. + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + fun testGetNumRecordedErrors(errorType: mozilla.components.service.glean.testing.ErrorType) = + inner.testGetNumRecordedErrors(errorType) +} diff --git a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/ErrorType.kt b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/ErrorType.kt new file mode 100644 index 0000000000..8f4e53d501 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/ErrorType.kt @@ -0,0 +1,10 @@ +/* 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.glean.testing + +/** + * Different types of errors that can be reported through Glean's error reporting metrics. + */ +typealias ErrorType = mozilla.telemetry.glean.testing.ErrorType diff --git a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/GleanTestLocalServer.kt b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/GleanTestLocalServer.kt new file mode 100644 index 0000000000..0a02b953bb --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/GleanTestLocalServer.kt @@ -0,0 +1,31 @@ +/* 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.glean.testing + +/** + * This implements a JUnit rule for writing tests for Glean SDK metrics. + * + * The rule takes care of sending Glean SDK pings to a local server, at the + * address: "http://localhost:<port>". + * + * This is useful for Android instrumented tests, where we don't want to + * initialize Glean more than once but still want to send pings to a local + * server for validation. + * + * FIXME(bug 1787234): State of the local server can persist across multiple test classes, + * leading to hard-to-diagnose intermittent test failures. + * It might be necessary to limit use of `GleanTestLocalServer` to a single test class for now. + * + * Example usage: + * + * ``` + * // Add the following lines to you test class. + * @get:Rule + * val gleanRule = GleanTestLocalServer(3785) + * ``` + * + * @param localPort the port of the local ping server + */ +typealias GleanTestLocalServer = mozilla.telemetry.glean.testing.GleanTestLocalServer diff --git a/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/GleanTestRule.kt b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/GleanTestRule.kt new file mode 100644 index 0000000000..91d20fad70 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/main/java/mozilla/components/service/glean/testing/GleanTestRule.kt @@ -0,0 +1,7 @@ +/* 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.glean.testing + +typealias GleanTestRule = mozilla.telemetry.glean.testing.GleanTestRule diff --git a/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/GleanFromJavaTest.java b/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/GleanFromJavaTest.java new file mode 100644 index 0000000000..c495e68bbc --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/GleanFromJavaTest.java @@ -0,0 +1,68 @@ +/* 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.glean; + +import androidx.test.core.app.ApplicationProvider; +import androidx.work.testing.WorkManagerTestInitHelper; + +import android.content.Context; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.util.Calendar; +import java.util.HashMap; +import java.util.Map; + +import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient; +import mozilla.components.service.glean.config.Configuration; +import mozilla.components.service.glean.net.ConceptFetchHttpUploader; +import mozilla.telemetry.glean.BuildInfo; + +@RunWith(RobolectricTestRunner.class) +public class GleanFromJavaTest { + // The only purpose of these tests is to make sure the Glean API is + // callable from Java. If something goes wrong, it should complain about missing + // methods at build-time. + + @Test + public void testInitGleanWithDefaults() { + Context context = ApplicationProvider.getApplicationContext(); + WorkManagerTestInitHelper.initializeTestWorkManager(context); + ConceptFetchHttpUploader httpClient = ConceptFetchHttpUploader.fromClient(new HttpURLConnectionClient()); + Configuration config = new Configuration(httpClient); + BuildInfo buildInfo = new BuildInfo("test", "test", Calendar.getInstance()); + Glean.INSTANCE.initialize(context, true, config, buildInfo); + } + + @Test + public void testInitGleanWithConfiguration() { + Context context = ApplicationProvider.getApplicationContext(); + WorkManagerTestInitHelper.initializeTestWorkManager(context); + ConceptFetchHttpUploader httpClient = ConceptFetchHttpUploader.fromClient(new HttpURLConnectionClient()); + Configuration config = + new Configuration(httpClient, Configuration.DEFAULT_TELEMETRY_ENDPOINT, "test-channel"); + BuildInfo buildInfo = new BuildInfo("test", "test", Calendar.getInstance()); + Glean.INSTANCE.initialize(context, true, config, buildInfo); + } + + @Test + public void testGleanExperimentsAPIWithDefaults() { + Glean.INSTANCE.setExperimentActive("test-exp-id-1", "test-branch-1"); + } + + @Test + public void testGleanExperimentsAPIWithOptional() { + Map<String, String> experimentProperties = new HashMap<>(); + experimentProperties.put("test-prop1", "test-prop-result1"); + + Glean.INSTANCE.setExperimentActive( + "test-exp-id-1", + "test-branch-1", + experimentProperties + ); + } +} diff --git a/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/GleanTest.kt b/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/GleanTest.kt new file mode 100644 index 0000000000..7eaae408f2 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/GleanTest.kt @@ -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/. */ + +package mozilla.components.service.glean + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import mozilla.components.service.glean.private.BooleanMetricType +import mozilla.components.service.glean.private.CommonMetricData +import mozilla.components.service.glean.private.Lifetime +import mozilla.components.service.glean.testing.GleanTestRule +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GleanTest { + private val context: Context + get() = ApplicationProvider.getApplicationContext() + + @get:Rule + val gleanRule = GleanTestRule(context) + + @Test + fun `Glean correctly initializes and records a metric`() { + // Define a 'booleanMetric' boolean metric, which will be stored in "store1" + val booleanMetric = BooleanMetricType( + CommonMetricData( + disabled = false, + category = "telemetry", + lifetime = Lifetime.APPLICATION, + name = "boolean_metric", + sendInPings = listOf("store1"), + ), + ) + + booleanMetric.set(true) + + assertTrue(booleanMetric.testGetValue()!!) + } +} diff --git a/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/net/ConceptFetchHttpUploaderTest.kt b/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/net/ConceptFetchHttpUploaderTest.kt new file mode 100644 index 0000000000..48d0d6c3a4 --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/test/java/mozilla/components/service/glean/net/ConceptFetchHttpUploaderTest.kt @@ -0,0 +1,340 @@ +/* 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.glean.net + +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient +import mozilla.components.lib.fetch.okhttp.OkHttpClient +import mozilla.components.support.test.any +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.mock +import mozilla.telemetry.glean.config.Configuration +import mozilla.telemetry.glean.net.HttpStatus +import mozilla.telemetry.glean.net.RecoverableFailure +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import java.io.IOException +import java.net.CookieHandler +import java.net.CookieManager +import java.net.HttpCookie +import java.net.URI +import java.util.concurrent.TimeUnit + +class ConceptFetchHttpUploaderTest { + private val testPath: String = "/some/random/path/not/important" + private val testPing: String = "{ 'ping': 'test' }" + private val testDefaultConfig = Configuration() + + /** + * Create a mock webserver that accepts all requests. + * @return a [MockWebServer] instance + */ + private fun getMockWebServer(): MockWebServer { + val server = MockWebServer() + server.dispatcher = + object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + return MockResponse().setBody("OK") + } + } + + return server + } + + @Test + fun `connection timeouts must be properly set`() { + val uploader = + spy<ConceptFetchHttpUploader>(ConceptFetchHttpUploader(lazy { HttpURLConnectionClient() })) + + val request = uploader.buildRequest(testPath, testPing.toByteArray(), emptyMap()) + + assertEquals( + Pair(ConceptFetchHttpUploader.DEFAULT_READ_TIMEOUT, TimeUnit.MILLISECONDS), + request.readTimeout, + ) + assertEquals( + Pair(ConceptFetchHttpUploader.DEFAULT_CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS), + request.connectTimeout, + ) + } + + @Test + fun `Glean headers are correctly dispatched`() { + val mockClient: Client = mock() + `when`(mockClient.fetch(any())).thenReturn( + Response("URL", 200, mock(), mock()), + ) + + val expectedHeaders = mapOf( + "Content-Type" to "application/json; charset=utf-8", + "Test-header" to "SomeValue", + "OtherHeader" to "Glean/Test 25.0.2", + ) + + val uploader = ConceptFetchHttpUploader(lazy { mockClient }) + uploader.upload(testPath, testPing.toByteArray(), expectedHeaders) + val requestCaptor = argumentCaptor<Request>() + verify(mockClient).fetch(requestCaptor.capture()) + + expectedHeaders.forEach { (headerName, headerValue) -> + assertEquals( + headerValue, + requestCaptor.value.headers!![headerName], + ) + } + } + + @Test + fun `Cookie policy must be properly set`() { + val uploader = + spy<ConceptFetchHttpUploader>(ConceptFetchHttpUploader(lazy { HttpURLConnectionClient() })) + + val request = uploader.buildRequest(testPath, testPing.toByteArray(), emptyMap()) + + assertEquals(request.cookiePolicy, Request.CookiePolicy.OMIT) + } + + @Test + fun `upload() returns true for successful submissions (200)`() { + val mockClient: Client = mock() + `when`(mockClient.fetch(any())).thenReturn( + Response( + "URL", + 200, + mock(), + mock(), + ), + ) + + val uploader = spy<ConceptFetchHttpUploader>(ConceptFetchHttpUploader(lazy { mockClient })) + + assertEquals(HttpStatus(200), uploader.upload(testPath, testPing.toByteArray(), emptyMap())) + } + + @Test + fun `upload() returns false for server errors (5xx)`() { + for (responseCode in 500..527) { + val mockClient: Client = mock() + `when`(mockClient.fetch(any())).thenReturn( + Response( + "URL", + responseCode, + mock(), + mock(), + ), + ) + + val uploader = spy<ConceptFetchHttpUploader>(ConceptFetchHttpUploader(lazy { mockClient })) + + assertEquals(HttpStatus(responseCode), uploader.upload(testPath, testPing.toByteArray(), emptyMap())) + } + } + + @Test + fun `upload() returns true for successful submissions (2xx)`() { + for (responseCode in 200..226) { + val mockClient: Client = mock() + `when`(mockClient.fetch(any())).thenReturn( + Response( + "URL", + responseCode, + mock(), + mock(), + ), + ) + + val uploader = spy<ConceptFetchHttpUploader>(ConceptFetchHttpUploader(lazy { mockClient })) + + assertEquals(HttpStatus(responseCode), uploader.upload(testPath, testPing.toByteArray(), emptyMap())) + } + } + + @Test + fun `upload() returns true for failing submissions with broken requests (4xx)`() { + for (responseCode in 400..451) { + val mockClient: Client = mock() + `when`(mockClient.fetch(any())).thenReturn( + Response( + "URL", + responseCode, + mock(), + mock(), + ), + ) + + val uploader = spy<ConceptFetchHttpUploader>(ConceptFetchHttpUploader(lazy { mockClient })) + + assertEquals(HttpStatus(responseCode), uploader.upload(testPath, testPing.toByteArray(), emptyMap())) + } + } + + @Test + fun `upload() correctly uploads the ping data with default configuration`() { + val server = getMockWebServer() + + val client = ConceptFetchHttpUploader(lazy { HttpURLConnectionClient() }) + + val submissionUrl = "http://" + server.hostName + ":" + server.port + testPath + assertEquals(HttpStatus(200), client.upload(submissionUrl, testPing.toByteArray(), mapOf("test" to "header"))) + + val request = server.takeRequest() + assertEquals(testPath, request.path) + assertEquals("POST", request.method) + assertEquals(testPing, request.body.readUtf8()) + assertEquals("header", request.getHeader("test")) + + server.shutdown() + } + + @Test + fun `upload() correctly uploads the ping data with httpurlconnection client`() { + val server = getMockWebServer() + + val client = ConceptFetchHttpUploader(lazy { HttpURLConnectionClient() }) + + val submissionUrl = "http://" + server.hostName + ":" + server.port + testPath + assertEquals(HttpStatus(200), client.upload(submissionUrl, testPing.toByteArray(), mapOf("test" to "header"))) + + val request = server.takeRequest() + assertEquals(testPath, request.path) + assertEquals("POST", request.method) + assertEquals(testPing, request.body.readUtf8()) + assertEquals("header", request.getHeader("test")) + assertTrue(request.headers.values("Cookie").isEmpty()) + + server.shutdown() + } + + @Test + fun `upload() correctly uploads the ping data with OkHttp client`() { + val server = getMockWebServer() + + val client = ConceptFetchHttpUploader(lazy { OkHttpClient() }) + + val submissionUrl = "http://" + server.hostName + ":" + server.port + testPath + assertEquals(HttpStatus(200), client.upload(submissionUrl, testPing.toByteArray(), mapOf("test" to "header"))) + + val request = server.takeRequest() + assertEquals(testPath, request.path) + assertEquals("POST", request.method) + assertEquals(testPing, request.body.readUtf8()) + assertEquals("header", request.getHeader("test")) + assertTrue(request.headers.values("Cookie").isEmpty()) + + server.shutdown() + } + + @Test + fun `upload() must not transmit any cookie`() { + val server = getMockWebServer() + + val testConfig = testDefaultConfig.copy( + serverEndpoint = "http://localhost:" + server.port, + ) + + // Set the default cookie manager/handler to be used for the http upload. + val cookieManager = CookieManager() + CookieHandler.setDefault(cookieManager) + + // Store a sample cookie. + val cookie = HttpCookie("cookie-time", "yes") + cookie.domain = testConfig.serverEndpoint + cookie.path = testPath + cookie.version = 0 + cookieManager.cookieStore.add(URI(testConfig.serverEndpoint), cookie) + + // Store a cookie for a subdomain of the same domain's as the server endpoint, + // to make sure we don't accidentally remove it. + val cookie2 = HttpCookie("cookie-time2", "yes") + cookie2.domain = "sub.localhost" + cookie2.path = testPath + cookie2.version = 0 + cookieManager.cookieStore.add(URI("http://sub.localhost:${server.port}/test"), cookie2) + + // Add another cookie for the same domain. This one should be removed as well. + val cookie3 = HttpCookie("cookie-time3", "yes") + cookie3.domain = "localhost" + cookie3.path = testPath + cookie3.version = 0 + cookieManager.cookieStore.add(URI("http://localhost:${server.port}/test"), cookie3) + + // Trigger the connection. + val client = ConceptFetchHttpUploader(lazy { HttpURLConnectionClient() }) + val submissionUrl = testConfig.serverEndpoint + testPath + assertEquals(HttpStatus(200), client.upload(submissionUrl, testPing.toByteArray(), emptyMap())) + + val request = server.takeRequest() + assertEquals(testPath, request.path) + assertEquals("POST", request.method) + assertEquals(testPing, request.body.readUtf8()) + assertTrue(request.headers.values("Cookie").isEmpty()) + + // Check that we still have a cookie. + assertEquals(1, cookieManager.cookieStore.cookies.size) + assertEquals("cookie-time2", cookieManager.cookieStore.cookies[0].name) + + server.shutdown() + } + + @Test + fun `upload() should return false when upload fails`() { + val mockClient: Client = mock() + `when`(mockClient.fetch(any())).thenThrow(IOException()) + + val uploader = spy<ConceptFetchHttpUploader>(ConceptFetchHttpUploader(lazy { mockClient })) + + // And IOException during upload is a failed upload that we should retry. The client should + // return false in this case. + assertEquals(RecoverableFailure(0), uploader.upload("path", "ping".toByteArray(), emptyMap())) + } + + @Test + fun `the lazy client should only be instantiated after the first upload`() { + val mockClient: Client = mock() + `when`(mockClient.fetch(any())).thenReturn( + Response("URL", 200, mock(), mock()), + ) + val uploader = spy<ConceptFetchHttpUploader>(ConceptFetchHttpUploader(lazy { mockClient })) + assertFalse(uploader.client.isInitialized()) + + // After calling upload, the client must get instantiated. + uploader.upload("path", "ping".toByteArray(), emptyMap()) + assertTrue(uploader.client.isInitialized()) + } + + @Test + fun `usePrivateRequest sends all requests with private flag`() { + val mockClient: Client = mock() + `when`(mockClient.fetch(any())).thenReturn( + Response("URL", 200, mock(), mock()), + ) + + val expectedHeaders = mapOf( + "Content-Type" to "application/json; charset=utf-8", + "Test-header" to "SomeValue", + "OtherHeader" to "Glean/Test 25.0.2", + ) + + val uploader = ConceptFetchHttpUploader(lazy { mockClient }, true) + uploader.upload(testPath, testPing.toByteArray(), expectedHeaders) + + val captor = argumentCaptor<Request>() + + verify(mockClient).fetch(captor.capture()) + + assertTrue(captor.value.private) + } +} diff --git a/mobile/android/android-components/components/service/glean/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/service/glean/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/glean/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/mobile/android/android-components/components/service/glean/src/test/resources/robolectric.properties b/mobile/android/android-components/components/service/glean/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/service/glean/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 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 diff --git a/mobile/android/android-components/components/service/nimbus/.gitignore b/mobile/android/android-components/components/service/nimbus/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/.gitignore @@ -0,0 +1 @@ +/build diff --git a/mobile/android/android-components/components/service/nimbus/README.md b/mobile/android/android-components/components/service/nimbus/README.md new file mode 100644 index 0000000000..b697a2b03c --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/README.md @@ -0,0 +1,352 @@ +# [Android Components](../../../README.md) > Service > Nimbus + +A wrapper for the Nimbus SDK. + +Contents: + +- [Usage](#usage) + +## 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-nimbus:{latest-version}" +``` + +### Initializing the Experiments library + +**TODO** + +### Updating of experiments + +**TODO** + +### Checking if a user is part of an experiment + +**TODO** + +## Testing Nimbus + +This section contains information about the Kinto and UI schemas needed to set up and run experiments on the "Dev" Kinto instance located at https://kinto.dev.mozaws.net. +**NOTE** The dev server instance requires LDAP authorization, but does not require connection to the internal Mozilla VPN. + +## Where to add the Kinto and UI schemas + +For testing purposes, create a collection with an id of `nimbus-mobile-experiments` in the `main` bucket on the [Kinto dev server](https://kinto.dev.mozaws.net/v1/admin/). + +### JSON Schema + +```JSON +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "http://mozilla.org/example.json", + "type": "object", + "title": "Nimbus Schema", + "description": "This is the description of the current Nimbus experiment schema, which can be found at https://github.com/mozilla/nimbus-shared", + "default": {}, + "examples": [ + { + "slug": "secure-gold", + "endDate": null, + "branches": [ + { + "slug": "control", + "ratio": 1 + }, + { + "slug": "treatment", + "ratio": 1 + } + ], + "probeSets": [], + "startDate": null, + "application": "org.mozilla.fenix", + "bucketConfig": { + "count": 100, + "start": 0, + "total": 10000, + "namespace": "secure-gold", + "randomizationUnit": "nimbus_id" + }, + "userFacingName": "Diagnostic test experiment", + "referenceBranch": "control", + "isEnrollmentPaused": false, + "proposedEnrollment": 7, + "userFacingDescription": "This is a test experiment for diagnostic purposes.", + "id": "secure-gold" + } + ], + "required": [ + "slug", + "branches", + "application", + "bucketConfig", + "userFacingName", + "referenceBranch", + "isEnrollmentPaused", + "proposedEnrollment", + "userFacingDescription", + "id" + ], + "properties": { + "slug": { + "$id": "#/properties/slug", + "type": "string", + "title": "Slug", + "description": "The slug is the unique identifier for the experiment.", + "default": "", + "examples": ["fenix-search-widget-experiment"] + }, + "endDate": { + "$id": "#/properties/endDate", + "type": ["string", "null"], + "format": "date-time", + "title": "End Date", + "description": "This is the date that the experiment will end.", + "default": null, + "examples": [null] + }, + "branches": { + "$id": "#/properties/branches", + "type": "array", + "title": "Branches", + "description": "Branches relate to the specific treatments to be applied for the experiment.", + "default": [], + "examples": [ + [ + { + "slug": "control", + "ratio": 1 + }, + { + "slug": "treatment", + "ratio": 1 + } + ] + ], + "additionalItems": true, + "items": { + "$id": "#/properties/branches/items", + "anyOf": [ + { + "$id": "#/properties/branches/items/anyOf/0", + "type": "object", + "title": "Branch Items", + "description": "Each branch has a slug, or name, and a ratio that weights selection into that branch", + "default": {}, + "examples": [ + { + "slug": "control", + "ratio": 1 + } + ], + "required": ["slug", "ratio"], + "properties": { + "slug": { + "$id": "#/properties/branches/items/anyOf/0/properties/slug", + "type": "string", + "title": "Branch Slug", + "description": "The branch slug is the unique name of the branch, within this experiment.", + "default": "control", + "examples": ["control"] + }, + "ratio": { + "$id": "#/properties/branches/items/anyOf/0/properties/ratio", + "type": "integer", + "title": "Branch Ratio", + "description": "This is the weighting of the branch for branch selection.", + "default": 1, + "examples": [1] + } + }, + "additionalProperties": true + } + ] + } + }, + "probeSets": { + "$id": "#/properties/probeSets", + "type": "array", + "title": "Probe Sets", + "description": "Currently unimplemented/used", + "default": [], + "examples": [[]], + "additionalItems": true, + "items": { + "$id": "#/properties/probeSets/items" + } + }, + "startDate": { + "$id": "#/properties/startDate", + "type": ["string", "null"], + "format": "date-time", + "title": "Start Date", + "description": "The date that the experiment will start", + "default": null, + "examples": [null] + }, + "application": { + "$id": "#/properties/application", + "type": "string", + "title": "Application", + "description": "This is the application to target", + "default": "", + "examples": [ + "org.mozilla.fenix", + "org.mozilla.firefox", + "org.mozilla.ios.firefox" + ] + }, + "bucketConfig": { + "$id": "#/properties/bucketConfig", + "type": "object", + "title": "Bucket Configuration", + "description": "This is the configuration of the bucketing for determining the experiment sample size", + "default": {}, + "examples": [ + { + "count": 2000, + "start": 0, + "total": 10000, + "namespace": "performance-experiments", + "randomizationUnit": "nimbus_id" + } + ], + "required": ["count", "start", "total", "namespace", "randomizationUnit"], + "properties": { + "count": { + "$id": "#/properties/bucketConfig/properties/count", + "type": "integer", + "title": "Count", + "description": "The total count of buckets to assign to be eligible to enroll in the experiment.", + "default": 0, + "examples": [2000] + }, + "start": { + "$id": "#/properties/bucketConfig/properties/start", + "type": "integer", + "title": "Starting Bucket", + "description": "This is the bucket that the count of buckets will start from.", + "default": 0, + "examples": [0] + }, + "total": { + "$id": "#/properties/bucketConfig/properties/total", + "type": "integer", + "title": "Total Buckets", + "description": "This is the total number of buckets to divide the population into for enrollment purposes.", + "default": 10000, + "examples": [10000] + }, + "namespace": { + "$id": "#/properties/bucketConfig/properties/namespace", + "type": "string", + "title": "Namespace", + "description": "This is the bucket namespace and should always match the experiment slug", + "default": "", + "examples": ["secure-gold"] + }, + "randomizationUnit": { + "$id": "#/properties/bucketConfig/properties/randomizationUnit", + "type": "string", + "title": "Randomization Unit", + "description": "This is the id to use for randomization for the purpose of bucketing. Currently only nimbus_id implemented.", + "default": "nimbus_id", + "examples": ["nimbus_id"] + } + }, + "additionalProperties": true + }, + "userFacingName": { + "$id": "#/properties/userFacingName", + "type": "string", + "title": "User Facing Name", + "description": "The user-facing name of the experiment.", + "default": "", + "examples": ["Diagnostic test experiment"] + }, + "referenceBranch": { + "$id": "#/properties/referenceBranch", + "type": "string", + "title": "Reference Branch", + "description": "Not currently implemented, do not change default", + "default": "control", + "examples": ["control"] + }, + "isEnrollmentPaused": { + "$id": "#/properties/isEnrollmentPaused", + "type": "boolean", + "title": "Enrollment Paused", + "description": "True if the enrollment is paused, false if enrollment is active.", + "default": false, + "examples": [false] + }, + "proposedEnrollment": { + "$id": "#/properties/proposedEnrollment", + "type": "integer", + "title": "Proposed Enrollment", + "description": "The length in days that enrollment is proposed.", + "default": 7, + "examples": [7] + }, + "userFacingDescription": { + "$id": "#/properties/userFacingDescription", + "type": "string", + "title": "User Facing Description", + "description": "This is the description of the experiment that would be presented to the user.", + "default": "", + "examples": ["This is a test experiment for diagnostic purposes."] + }, + "id": { + "$id": "#/properties/id", + "type": "string", + "title": "ID", + "description": "An analog of the slug? Not sure, make this match slug...", + "default": "", + "examples": ["secure-gold"] + } + }, + "additionalProperties": true +} +``` + +## UI Schema + +```JSON +{ + "ui:order": [ + "slug", + "userFacingName", + "userFacingDescription", + "application", + "startDate", + "endDate", + "bucketConfig", + "branches", + "referenceBranch", + "isEnrollmentPaused", + "proposedEnrollment", + "id", + "probeSets" + ], + "userFacingDescription": { + "ui:widget": "textarea" + }, + "bucketConfig": { + "ui:order": ["start", "count", "total", "namespace", "randomizationUnit"] + }, + "branches": { + "ui:order": ["slug", "ratio"] + } +} + +``` + +## 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/nimbus/build.gradle b/mobile/android/android-components/components/service/nimbus/build.gradle new file mode 100644 index 0000000000..b8ef48ec0a --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/build.gradle @@ -0,0 +1,122 @@ +/* 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/. */ + +buildscript { + repositories { + gradle.mozconfig.substs.GRADLE_MAVEN_REPOSITORIES.each { repository -> + maven { + url repository + if (gradle.mozconfig.substs.ALLOW_INSECURE_GRADLE_REPOSITORIES) { + allowInsecureProtocol = true + } + } + } + } + + dependencies { + classpath "${ApplicationServicesConfig.groupId}:tooling-nimbus-gradle:${ApplicationServicesConfig.version}" + classpath "org.mozilla.telemetry:glean-gradle-plugin:${Versions.mozilla_glean}" + } +} + +plugins { + id "com.jetbrains.python.envs" version "$python_envs_plugin" +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + } + + buildTypes { + debug { + // Export experiments proguard rules even in debug since consuming apps may still + // enable proguard/R8 + consumerProguardFiles 'proguard-rules-consumer.pro' + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + consumerProguardFiles 'proguard-rules-consumer.pro' + } + } + + buildFeatures { + buildConfig true + } + + namespace 'mozilla.components.service.nimbus' +} + +dependencies { + // These dependencies are part of this module's public API. + api (ComponentsDependencies.mozilla_appservices_nimbus) { + // Use our own version of the Glean dependency, + // which might be different from the version declared by A-S. + exclude group: 'org.mozilla.components', module: 'service-glean' + } + + implementation ComponentsDependencies.androidx_core_ktx + implementation ComponentsDependencies.androidx_constraintlayout + implementation ComponentsDependencies.androidx_coordinatorlayout + implementation ComponentsDependencies.androidx_recyclerview + implementation ComponentsDependencies.androidx_work_runtime + + implementation ComponentsDependencies.kotlin_coroutines + + implementation ComponentsDependencies.mozilla_appservices_nimbus + + implementation project(':support-base') + implementation project(':support-locale') + implementation project(':support-ktx') + implementation project(':support-utils') + + // We only compile against GeckoView and Glean. It's up to the app to add those dependencies if it wants to + // send crash reports to Socorro (GV). + compileOnly project(":service-glean") + + testImplementation ComponentsDependencies.mozilla_appservices_full_megazord_forUnitTests + testImplementation ComponentsDependencies.androidx_test_core + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.testing_mockwebserver + testImplementation ComponentsDependencies.testing_robolectric + testImplementation ComponentsDependencies.testing_coroutines + testImplementation ComponentsDependencies.mozilla_glean_forUnitTests + testImplementation ComponentsDependencies.androidx_work_testing + testImplementation project(':support-test') + testImplementation project(":service-glean") +} + +apply from: '../../../android-lint.gradle' +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) + + +apply plugin: "org.mozilla.appservices.nimbus-gradle-plugin" + +nimbus { + // The path to the Nimbus feature manifest file + manifestFile = "messaging.fml.yaml" + + // Map from the variant name to the channel as experimenter and nimbus understand it. + // If nimbus's channels were accurately set up well for this project, then this + // shouldn't be needed. + channels = [ + debug: "debug", + release: "release", + ] + + // This is an optional value, and updates the plugin to use a copy of application + // services. The path should be relative to the root project directory. + // *NOTE*: This example will not work for all projects, but should work for Fenix, Focus, and Android Components + applicationServicesDir = gradle.hasProperty('localProperties.autoPublish.application-services.dir') + ? gradle.getProperty('localProperties.autoPublish.application-services.dir') : null +} + +apply plugin: "org.mozilla.telemetry.glean-gradle-plugin" diff --git a/mobile/android/android-components/components/service/nimbus/messaging.fml.yaml b/mobile/android/android-components/components/service/nimbus/messaging.fml.yaml new file mode 100644 index 0000000000..c53456e31e --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/messaging.fml.yaml @@ -0,0 +1,194 @@ +--- +about: + description: Nimbus Feature Manifest for Android + kotlin: + package: mozilla.components.service.nimbus + class: .messaging.FxNimbusMessaging +channels: + - release + - debug +features: + nimbus-system: + description: | + Configuration of the Nimbus System in Android. + variables: + refresh-interval-foreground: + description: | + The minimum interval in minutes between fetching experiment + recipes in the foreground. + type: Int + default: 60 # 1 hour + + messaging: + description: | + The in-app messaging system. + + allow-coenrollment: true + + variables: + messages: + description: A growable collection of messages + type: Map<MessageKey, MessageData> + string-alias: MessageKey + default: {} + + triggers: + description: > + A collection of out the box trigger + expressions. Each entry maps to a + valid JEXL expression. + type: Map<TriggerName, String> + string-alias: TriggerName + default: {} + styles: + description: > + A map of styles to configure message + appearance. + type: Map<StyleName, StyleData> + string-alias: StyleName + default: {} + + $$surfaces: + description: | + A list available surfaces for this app. + + This should not be written to by experiments, and should be hidden to users. + type: List<SurfaceName> + string-alias: SurfaceName + default: [] + + actions: + type: Map<ActionName, String> + description: A growable map of action URLs. + string-alias: ActionName + default: + OPEN_URL: ://open + + on-control: + type: ControlMessageBehavior + description: What should be displayed when a control message is selected. + default: show-next-message + notification-config: + description: Configuration of the notification worker for all notification messages. + type: NotificationConfig + default: {} + message-under-experiment: + description: Deprecated in favor of `MessageData#experiment`. This will be removed in future releases. + type: Option<MessageKey> + default: null + $$experiment: + description: The only acceptable value for `MessageData#experiment`. This should not be set by experiment. + type: ExperimentSlug + string-alias: ExperimentSlug + default: "{experiment}" + defaults: + +objects: + MessageData: + description: > + An object to describe a message. It uses human + readable strings to describe the triggers, action and + style of the message as well as the text of the message + and call to action. + fields: + action: + type: ActionName + description: > + A URL of a page or a deeplink. + This may have substitution variables in. + # This should never be defaulted. + default: OPEN_URL + action-params: + description: > + A string map containing query parameters that will be appended to the action URL. + This is useful for opening URLs in tabs, or specifying that the tab should be private. + The values may have substitutions, e.g. "url": "https://example.com/id={uuid}", + "private": "true". + + The params and their values are all determined downstream of the messaging component, by + the embedding app's deeplink processing machinery. + type: Map<String, String> + default: {} + title: + type: Option<Text> + description: "The title text displayed to the user" + default: null + text: + type: Text + description: "The message text displayed to the user" + # This should never be defaulted. + default: "" + is-control: + type: Boolean + description: "Indicates if this message is the control message, if true shouldn't be displayed" + default: false + experiment: + type: Option<ExperimentSlug> + description: The slug of the experiment that this message came from. + default: null + button-label: + type: Option<Text> + description: > + The text on the button. If no text + is present, the whole message is clickable. + default: null + style: + type: StyleName + description: > + The style as described in a + `StyleData` from the styles table. + default: DEFAULT + surface: + description: + The surface identifier for this message. + type: SurfaceName + default: homescreen + trigger-if-all: + type: List<TriggerName> + description: > + A list of strings corresponding to + targeting expressions. The message will be + shown if all expressions are `true`. + default: [] + exclude-if-any: + type: List<TriggerName> + description: > + A list of strings corresponding to + targeting expressions. The message will not be + shown if any of the expressions are `true`. + default: [ ] + StyleData: + description: > + A group of properties (predominantly visual) to + describe the style of the message. + fields: + priority: + type: Int + description: > + The importance of this message. + 0 is not very important, 100 is very important. + default: 50 + max-display-count: + type: Int + description: > + How many sessions will this message be shown to the user + before it is expired. + default: 5 + NotificationConfig: + description: Attributes controlling the global configuration of notification messages. + fields: + refresh-interval: + type: Int + description: > + How often, in minutes, the notification message worker will wake up and check for new + messages. + default: 240 # 4 hours + +enums: + ControlMessageBehavior: + description: An enum to influence what should be displayed when a control message is selected. + variants: + show-next-message: + description: The next eligible message should be shown. + show-none: + description: The surface should show no message. diff --git a/mobile/android/android-components/components/service/nimbus/metrics.yaml b/mobile/android/android-components/components/service/nimbus/metrics.yaml new file mode 100644 index 0000000000..4f02dd76f5 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/metrics.yaml @@ -0,0 +1,110 @@ +# 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/. + +# This file defines the metrics that are recorded by glean telemetry. They are +# automatically converted to Kotlin code at build time using the `glean_parser` +# PyPI package. +--- + +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 + +messaging: + message_shown: + type: event + description: | + A message was shown to the user. + extra_keys: + message_key: + description: The id of the message + type: string + bugs: + - https://github.com/mozilla-mobile/fenix/issues/24224 + data_reviews: + - https://github.com/mozilla-mobile/fenix/pull/24426 + - https://github.com/mozilla-mobile/firefox-android/pull/1101 + notification_emails: + - android-probes@mozilla.com + - cgordon@mozilla.com + data_sensitivity: + - interaction + expires: never + message_dismissed: + type: event + description: | + A message was dismissed by the user. + extra_keys: + message_key: + description: The id of the message + type: string + bugs: + - https://github.com/mozilla-mobile/fenix/issues/24224 + data_reviews: + - https://github.com/mozilla-mobile/fenix/issues/24224 + - https://github.com/mozilla-mobile/firefox-android/pull/1101 + notification_emails: + - android-probes@mozilla.com + - cgordon@mozilla.com + data_sensitivity: + - interaction + expires: never + message_clicked: + type: event + description: | + A message was clicked by the user. + extra_keys: + message_key: + description: The id of the message + type: string + action_uuid: + description: The uuid of the action + type: string + bugs: + - https://github.com/mozilla-mobile/fenix/issues/24224 + data_reviews: + - https://github.com/mozilla-mobile/fenix/issues/24224 + - https://github.com/mozilla-mobile/firefox-android/pull/1101 + notification_emails: + - android-probes@mozilla.com + - cgordon@mozilla.com + data_sensitivity: + - interaction + expires: never + message_expired: + type: event + description: | + A message maxDisplayCount has been surpassed. + extra_keys: + message_key: + description: The id of the message + type: string + bugs: + - https://github.com/mozilla-mobile/fenix/issues/24224 + data_reviews: + - https://github.com/mozilla-mobile/fenix/issues/24224 + - https://github.com/mozilla-mobile/firefox-android/pull/1101 + notification_emails: + - android-probes@mozilla.com + - cgordon@mozilla.com + data_sensitivity: + - interaction + expires: never + malformed: + type: event + description: | + A message was malformed. + extra_keys: + message_key: + description: The id of the message + type: string + bugs: + - https://github.com/mozilla-mobile/fenix/issues/24224 + data_reviews: + - https://github.com/mozilla-mobile/fenix/issues/24224 + - https://github.com/mozilla-mobile/firefox-android/pull/1101 + notification_emails: + - android-probes@mozilla.com + - cgordon@mozilla.com + data_sensitivity: + - interaction + expires: never diff --git a/mobile/android/android-components/components/service/nimbus/proguard-rules-consumer.pro b/mobile/android/android-components/components/service/nimbus/proguard-rules-consumer.pro new file mode 100644 index 0000000000..4865f3c386 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/proguard-rules-consumer.pro @@ -0,0 +1,4 @@ +# ProGuard rules for consumers of this library. + +# Experiments specific protections +-keep class mozilla.components.service.nimbus.** { *; } diff --git a/mobile/android/android-components/components/service/nimbus/proguard-rules.pro b/mobile/android/android-components/components/service/nimbus/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/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/nimbus/src/main/AndroidManifest.xml b/mobile/android/android-components/components/service/nimbus/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..1eccdee26a --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ +<!-- 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"> + + <application android:supportsRtl="true" /> +</manifest> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/Nimbus.kt b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/Nimbus.kt new file mode 100644 index 0000000000..f4f0f0e42e --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/Nimbus.kt @@ -0,0 +1,78 @@ +/* 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.nimbus + +import android.content.Context +import mozilla.components.support.base.observer.Observable +import mozilla.components.support.base.observer.ObserverRegistry +import org.mozilla.experiments.nimbus.EnrolledExperiment +import org.mozilla.experiments.nimbus.NimbusAppInfo +import org.mozilla.experiments.nimbus.NimbusDelegate +import org.mozilla.experiments.nimbus.NimbusDeviceInfo +import org.mozilla.experiments.nimbus.NimbusInterface +import org.mozilla.experiments.nimbus.NimbusServerSettings +import org.mozilla.experiments.nimbus.Nimbus as ApplicationServicesNimbus + +/** + * Union of NimbusInterface which comes from another repo, and Observable. + * + * This only exists to allow the [Nimbus] class to be interchangeable [NimbusDisabled] class below. + */ +interface NimbusApi : NimbusInterface, Observable<NimbusInterface.Observer> + +// Re-export these classes which were in this package previously. +// Clients which used these classes do not need to change. +typealias NimbusAppInfo = NimbusAppInfo +typealias NimbusServerSettings = NimbusServerSettings + +/** + * This is the main entry point to the Nimbus experiment subsystem. + * + * It can only be run after Glean has been set up, the megazord has finished loading, and viaduct + * has been initialized. + */ +class Nimbus( + context: Context, + appInfo: NimbusAppInfo, + coenrollingFeatureIds: List<String> = listOf(), + server: NimbusServerSettings?, + deviceInfo: NimbusDeviceInfo = NimbusDeviceInfo.default(), + delegate: NimbusDelegate = NimbusDelegate.default(), + private val observable: Observable<NimbusInterface.Observer> = ObserverRegistry(), +) : ApplicationServicesNimbus( + context = context, + appInfo = appInfo, + coenrollingFeatureIds = coenrollingFeatureIds, + server = server, + deviceInfo = deviceInfo, + delegate = delegate, + observer = Observer(observable), +), + NimbusApi, + Observable<NimbusInterface.Observer> by observable { + private class Observer(val observable: Observable<NimbusInterface.Observer>) : NimbusInterface.Observer { + override fun onExperimentsFetched() { + observable.notifyObservers { onExperimentsFetched() } + } + + override fun onUpdatesApplied(updated: List<EnrolledExperiment>) { + observable.notifyObservers { onUpdatesApplied(updated) } + } + } +} + +/** + * An empty implementation of the `NimbusInterface` to allow clients who have not enabled Nimbus (either + * by feature flags, or by not using a server endpoint. + * + * Any implementations using this class will report that the user has not been enrolled into any + * experiments, and will not report anything to Glean. Importantly, any calls to + * `getExperimentBranch(slug)` will return `null`, i.e. as if the user is not enrolled into the + * experiment. + */ +class NimbusDisabled( + override val context: Context, + private val observable: Observable<NimbusInterface.Observer> = ObserverRegistry(), +) : NimbusApi, Observable<NimbusInterface.Observer> by observable diff --git a/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/NimbusBuilder.kt b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/NimbusBuilder.kt new file mode 100644 index 0000000000..ca4af5e51b --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/NimbusBuilder.kt @@ -0,0 +1,54 @@ +/* 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.nimbus + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.asCoroutineDispatcher +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.base.utils.NamedThreadFactory +import org.mozilla.experiments.nimbus.AbstractNimbusBuilder +import org.mozilla.experiments.nimbus.NimbusDelegate +import java.util.concurrent.Executors + +private val logger = Logger("service/Nimbus") + +/** + * Class to build instances of Nimbus. + * + * This _does not_ invoke any networking calls on the subsequent [Nimbus] object, so may safely + * used before the engine is warmed up. + */ +class NimbusBuilder(context: Context) : AbstractNimbusBuilder<NimbusApi>(context) { + override fun createDelegate(): NimbusDelegate = + NimbusDelegate( + dbScope = createNamedCoroutineScope("NimbusDbScope"), + fetchScope = createNamedCoroutineScope("NimbusFetchScope"), + errorReporter = errorReporter, + logger = { logger.info(it) }, + ) + + override fun newNimbus( + appInfo: NimbusAppInfo, + serverSettings: NimbusServerSettings?, + ) = Nimbus( + context, + appInfo = appInfo, + coenrollingFeatureIds = getCoenrollingFeatureIds(), + server = serverSettings, + deviceInfo = createDeviceInfo(), + delegate = createDelegate(), + ).apply { + this.register(createObserver()) + } + + override fun newNimbusDisabled() = NimbusDisabled(context) +} + +private fun createNamedCoroutineScope(name: String) = CoroutineScope( + Executors.newSingleThreadExecutor( + NamedThreadFactory(name), + ).asCoroutineDispatcher(), +) diff --git a/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/NimbusUtils.kt b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/NimbusUtils.kt new file mode 100644 index 0000000000..ed090b14b2 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/NimbusUtils.kt @@ -0,0 +1,20 @@ +/* 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.nimbus + +import org.mozilla.experiments.nimbus.NimbusMessagingHelperInterface +import org.mozilla.experiments.nimbus.internal.NimbusException + +/** + * Extension method that returns true when the condition is evaluated to true, and false otherwise + * @param condition The condition given as String. + */ +fun NimbusMessagingHelperInterface.evalJexlSafe( + condition: String, +) = try { + evalJexl(condition) +} catch (e: NimbusException.EvaluationException) { + false +} diff --git a/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/JexlAttributeProvider.kt b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/JexlAttributeProvider.kt new file mode 100644 index 0000000000..9213e8568c --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/JexlAttributeProvider.kt @@ -0,0 +1,21 @@ +/* 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.nimbus.messaging + +import android.content.Context +import org.json.JSONObject + +/** + * A provider that will be used to evaluate if message is eligible to be shown. + */ +interface JexlAttributeProvider { + /** + * Returns a [JSONObject] that contains all the custom attributes, evaluated when the function + * was called. + * + * This is used to drive display triggers of messages. + */ + fun getCustomAttributes(context: Context): JSONObject +} diff --git a/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/Message.kt b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/Message.kt new file mode 100644 index 0000000000..46aa647c92 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/Message.kt @@ -0,0 +1,82 @@ +/* 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.nimbus.messaging + +import androidx.annotation.VisibleForTesting + +/** + * A data class that holds a representation of a message from Nimbus. + * + * In order to be eligible to be shown, all `triggerIfAll` expressions + * AND none of the `excludeIfAny` expressions must evaluate to `true`. + * + * @param id identifies a message as unique. + * @param data Data information provided from Nimbus. + * @param action A strings that represents which action should be performed + * after a message is clicked. + * @param style Indicates how a message should be styled. + * @param triggerIfAll A list of strings corresponding to JEXL targeting expressions. The message + * will be shown if _all_ expressions evaluate to `true`. + * @param excludeIfAny A list of strings corresponding to JEXL targeting expressions. The message + * will _not_ be shown if _any_ expressions evaluate to `true`. + * @param metadata Metadata that help to identify if a message should shown. + */ +data class Message( + val id: String, + internal val data: MessageData, + internal val action: String, + internal val style: StyleData, + internal val triggerIfAll: List<String>, + internal val excludeIfAny: List<String> = listOf(), + internal val metadata: Metadata, +) { + val text: String + get() = data.text + + val title: String? + get() = data.title + + val buttonLabel: String? + get() = data.buttonLabel + + val priority: Int + get() = style.priority + + val surface: MessageSurfaceId + get() = data.surface + + val isExpired: Boolean + get() = metadata.displayCount >= style.maxDisplayCount + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + val displayCount: Int + get() = metadata.displayCount + + /** + * Returns true if the passed boot id, taken from [BootUtils] matches the one associated + * with this message when it was last displayed. + */ + fun hasShownThisCycle(bootId: String) = bootId == metadata.latestBootIdentifier + + /** + * A data class that holds metadata that help to identify if a message should shown. + * + * @param id identifies a message as unique. + * @param displayCount Indicates how many times a message is displayed. + * @param pressed Indicates if a message has been clicked. + * @param dismissed Indicates if a message has been closed. + * @param lastTimeShown A timestamp indicating when was the last time, the message was shown. + * @param latestBootIdentifier A unique boot identifier for when the message was last displayed + * (this may be a boot count or a boot id). + */ + data class Metadata( + val id: String, + val displayCount: Int = 0, + val pressed: Boolean = false, + val dismissed: Boolean = false, + val lastTimeShown: Long = 0L, + val latestBootIdentifier: String? = null, + ) +} diff --git a/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/MessageMetadataStorage.kt b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/MessageMetadataStorage.kt new file mode 100644 index 0000000000..619c5607b1 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/MessageMetadataStorage.kt @@ -0,0 +1,26 @@ +/* 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.nimbus.messaging + +/** + * A storage that persists [Message.Metadata] into disk. + */ +interface MessageMetadataStorage { + /** + * Provide all the message metadata saved in the storage. + */ + suspend fun getMetadata(): Map<String, Message.Metadata> + + /** + * Given a [metadata] add the message metadata on the storage. + * @return the added message on the [MessageMetadataStorage] + */ + suspend fun addMetadata(metadata: Message.Metadata): Message.Metadata + + /** + * Given a [metadata] update the message metadata on the storage. + */ + suspend fun updateMetadata(metadata: Message.Metadata) +} diff --git a/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/MessageSurfaceId.kt b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/MessageSurfaceId.kt new file mode 100644 index 0000000000..e7f419c455 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/MessageSurfaceId.kt @@ -0,0 +1,10 @@ +/* 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.nimbus.messaging + +/** + * The identity of a message surface + */ +typealias MessageSurfaceId = String diff --git a/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/NimbusMessagingController.kt b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/NimbusMessagingController.kt new file mode 100644 index 0000000000..391dfe0cb7 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/NimbusMessagingController.kt @@ -0,0 +1,130 @@ +/* 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.nimbus.messaging + +import android.content.Intent +import android.net.Uri +import androidx.annotation.VisibleForTesting +import androidx.core.net.toUri +import mozilla.components.service.nimbus.GleanMetrics.Messaging as GleanMessaging + +/** + * Bookkeeping for message actions in terms of Glean messages and the messaging store. + * + * @param messagingStorage a NimbusMessagingStorage instance + * @param deepLinkScheme the deepLinkScheme for the app + * @param now will be used to get the current time + */ +open class NimbusMessagingController( + private val messagingStorage: NimbusMessagingStorage, + private val deepLinkScheme: String, +) : NimbusMessagingControllerInterface { + /** + * Records telemetry and metadata for a newly processed displayed message. + */ + override suspend fun onMessageDisplayed(displayedMessage: Message, bootIdentifier: String?): Message { + sendShownMessageTelemetry(displayedMessage.id) + val nextMessage = messagingStorage.onMessageDisplayed(displayedMessage, bootIdentifier) + if (nextMessage.isExpired) { + sendExpiredMessageTelemetry(nextMessage.id) + } + return nextMessage + } + + /** + * Called when a message has been dismissed by the user. + * + * Records a messageDismissed event, and records that the message + * has been dismissed. + */ + override suspend fun onMessageDismissed(message: Message) { + val messageMetadata = message.metadata + sendDismissedMessageTelemetry(messageMetadata.id) + val updatedMetadata = messageMetadata.copy(dismissed = true) + messagingStorage.updateMetadata(updatedMetadata) + } + + /** + * Called once the user has clicked on a message. + * + * This records that the message has been clicked on, but does not record a + * glean event. That should be done via [processMessageActionToUri]. + */ + override suspend fun onMessageClicked(message: Message) { + val messageMetadata = message.metadata + val updatedMetadata = messageMetadata.copy(pressed = true) + messagingStorage.updateMetadata(updatedMetadata) + } + + /** + * Create and return the relevant [Intent] for the given [Message]. + * + * @param message the [Message] to create the [Intent] for. + * @return an [Intent] using the processed [Message]. + */ + override fun getIntentForMessage(message: Message) = Intent( + Intent.ACTION_VIEW, + processMessageActionToUri(message), + ) + + /** + * Will attempt to get the [Message] for the given [id]. + * + * @param id the [Message.id] of the [Message] to try to match. + * @return the [Message] with a matching [id], or null if no [Message] has a matching [id]. + */ + override suspend fun getMessage(id: String): Message? { + return messagingStorage.getMessage(id) + } + + /** + * The [message] action needs to be examined for string substitutions + * and any `uuid` needs to be recorded in the Glean event. + * + * We call this `process` as it has a side effect of logging a Glean event while it + * creates a URI string for the message action. + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + fun processMessageActionToUri(message: Message): Uri { + val (uuid, action) = messagingStorage.generateUuidAndFormatMessage(message) + sendClickedMessageTelemetry(message.id, uuid) + + return convertActionIntoDeepLinkSchemeUri(action) + } + + private fun sendDismissedMessageTelemetry(messageId: String) { + GleanMessaging.messageDismissed.record(GleanMessaging.MessageDismissedExtra(messageId)) + } + + private fun sendShownMessageTelemetry(messageId: String) { + GleanMessaging.messageShown.record(GleanMessaging.MessageShownExtra(messageId)) + } + + private fun sendExpiredMessageTelemetry(messageId: String) { + GleanMessaging.messageExpired.record(GleanMessaging.MessageExpiredExtra(messageId)) + } + + private fun sendClickedMessageTelemetry(messageId: String, uuid: String?) { + GleanMessaging.messageClicked.record( + GleanMessaging.MessageClickedExtra(messageKey = messageId, actionUuid = uuid), + ) + } + + private fun convertActionIntoDeepLinkSchemeUri(action: String): Uri = + if (action.startsWith("://")) { + "$deepLinkScheme$action".toUri() + } else { + action.toUri() + } + + override suspend fun getMessages(): List<Message> = + messagingStorage.getMessages() + + override suspend fun getNextMessage(surfaceId: MessageSurfaceId) = + getNextMessage(surfaceId, getMessages()) + + override fun getNextMessage(surfaceId: MessageSurfaceId, messages: List<Message>) = + messagingStorage.getNextMessage(surfaceId, messages) +} diff --git a/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/NimbusMessagingControllerInterface.kt b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/NimbusMessagingControllerInterface.kt new file mode 100644 index 0000000000..0b7e08046f --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/NimbusMessagingControllerInterface.kt @@ -0,0 +1,73 @@ +/* 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.nimbus.messaging + +import android.content.Intent + +/** + * API for interacting with the messaging component. + * + * The primary methods for interacting with the component are: + * - [getNextMessage] to get the next [Message] for a given surface. + * - [onMessageDisplayed] to be called when the message is displayed. + * - [onMessageClicked] and [getIntentForMessage] to be called when the user taps on + * the message, and to get the action for the message. + * - [onMessageDismissed] to be called when the user dismisses the message. + */ +interface NimbusMessagingControllerInterface { + /** + * Get all messages currently on the system. This includes any that have expired, + * dismissed or clicked upon. + */ + suspend fun getMessages(): List<Message> + + /** + * Selects the next available message for the given surface from the given + * list of messages. + */ + fun getNextMessage(surfaceId: MessageSurfaceId, messages: List<Message>): Message? + + /** + * A convenience method for `getNextMessage(surfaceId, getMessages())`. + */ + suspend fun getNextMessage(surfaceId: MessageSurfaceId): Message? + + /** + * Records telemetry and metadata for a newly processed displayed message. + */ + suspend fun onMessageDisplayed(displayedMessage: Message, bootIdentifier: String? = null): Message + + /** + * Called when a message has been dismissed by the user. + * + * Records a messageDismissed event, and records that the message + * has been dismissed. + */ + suspend fun onMessageDismissed(message: Message) + + /** + * Called once the user has clicked on a message. + * + * This records that the message has been clicked on, but does not record a + * glean event. That should be done when calling [getIntentForMessage]. + */ + suspend fun onMessageClicked(message: Message) + + /** + * Create and return the relevant [Intent] for the given [Message]. + * + * @param message the [Message] to create the [Intent] for. + * @return an [Intent] using the processed [Message]. + */ + fun getIntentForMessage(message: Message): Intent + + /** + * Will attempt to get the [Message] for the given [id]. + * + * @param id the [Message.id] of the [Message] to try to match. + * @return the [Message] with a matching [id], or null if no [Message] has a matching [id]. + */ + suspend fun getMessage(id: String): Message? +} diff --git a/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/NimbusMessagingStorage.kt b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/NimbusMessagingStorage.kt new file mode 100644 index 0000000000..9828883337 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/NimbusMessagingStorage.kt @@ -0,0 +1,416 @@ +/* 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.nimbus.messaging + +import android.content.Context +import android.net.Uri +import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.Companion.PRIVATE +import kotlinx.coroutines.runBlocking +import mozilla.components.support.base.log.logger.Logger +import org.json.JSONObject +import org.mozilla.experiments.nimbus.NimbusMessagingHelperInterface +import org.mozilla.experiments.nimbus.NimbusMessagingInterface +import org.mozilla.experiments.nimbus.internal.FeatureHolder +import org.mozilla.experiments.nimbus.internal.NimbusException +import mozilla.components.service.nimbus.GleanMetrics.Messaging as GleanMessaging + +/** + * This ID must match the name given in the `messaging.fml.yaml` file, which + * itself generates the classname for [mozilla.components.service.nimbus.messaging.FxNimbusMessaging]. + * + * If that ever changes, it should also change here. + * + * This constant is the id for the messaging feature (the Nimbus feature). We declare it here + * so as to afford the best chance of it being changed if a rename operation is needed. + * + * It is used in the Studies view, to filter out any experiments which only use a messaging surface. + */ +const val MESSAGING_FEATURE_ID = "messaging" + +/** + * Provides messages from [messagingFeature] and combine with the metadata store on [metadataStorage]. + */ +class NimbusMessagingStorage( + private val context: Context, + private val metadataStorage: MessageMetadataStorage, + private val onMalformedMessage: (String) -> Unit = { + GleanMessaging.malformed.record(GleanMessaging.MalformedExtra(it)) + }, + private val nimbus: NimbusMessagingInterface, + private val messagingFeature: FeatureHolder<Messaging>, + private val attributeProvider: JexlAttributeProvider? = null, + private val now: () -> Long = { System.currentTimeMillis() }, +) { + /** + * Contains all malformed messages where they key can be the value or a trigger of the message + * and the value is the message id. + */ + @VisibleForTesting + val malFormedMap = mutableMapOf<String, String>() + private val logger = Logger("MessagingStorage") + private val customAttributes: JSONObject + get() = attributeProvider?.getCustomAttributes(context) ?: JSONObject() + + /** + * Returns a Nimbus message helper, for evaluating JEXL. + * + * The JEXL context is time-sensitive, so this should be created new for each set of evaluations. + * + * Since it has a native peer, it should be [destroy]ed after finishing the set of evaluations. + */ + fun createMessagingHelper(): NimbusMessagingHelperInterface = + nimbus.createMessageHelper(customAttributes) + + /** + * Returns the [Message] for the given [key] or returns null if none found. + */ + suspend fun getMessage(key: String): Message? = + createMessage(messagingFeature.value(), key) + + private suspend fun createMessage(featureValue: Messaging, key: String): Message? { + val message = createMessageOrNull(featureValue, key) + if (message == null) { + reportMalformedMessage(key) + } + return message + } + + @Suppress("ReturnCount") + private suspend fun createMessageOrNull(featureValue: Messaging, key: String): Message? { + val message = featureValue.messages[key] ?: return null + + val action = if (!message.isControl) { + if (message.text.isBlank()) { + return null + } + sanitizeAction(message.action, featureValue.actions) + ?: return null + } else { + "CONTROL_ACTION" + } + + val triggerIfAll = sanitizeTriggers(message.triggerIfAll, featureValue.triggers) ?: return null + val excludeIfAny = sanitizeTriggers(message.excludeIfAny, featureValue.triggers) ?: return null + val style = sanitizeStyle(message.style, featureValue.styles) ?: return null + + val storageMetadata = metadataStorage.getMetadata() + + return Message( + id = key, + data = message, + action = action, + style = style, + triggerIfAll = triggerIfAll, + excludeIfAny = excludeIfAny, + metadata = storageMetadata[key] ?: addMetadata(key), + ) + } + + @VisibleForTesting(otherwise = PRIVATE) + internal fun reportMalformedMessage(key: String) { + messagingFeature.recordMalformedConfiguration(key) + onMalformedMessage(key) + } + + /** + * Returns a list of currently available messages descending sorted by their [StyleData.priority]. + * + * "Currently available" means all messages contained in the Nimbus SDK, validated and denormalized. + * + * The messages have the JEXL triggers and actions that came from the Nimbus SDK, but these themselves + * are not validated at this time. + * + * The messages also have state attached, which manage how many times the messages has been shown, + * and if the user has interacted with it or not. + * + * The list of messages may also contain control messages which should not be shown to the user. + * + * All additional filtering of these messages will happen in [getNextMessage]. + */ + suspend fun getMessages(): List<Message> { + val featureValue = messagingFeature.value() + val nimbusMessages = featureValue.messages + return nimbusMessages.keys + .mapNotNull { key -> + createMessage(featureValue, key) + }.sortedByDescending { + it.style.priority + } + } + + /** + * Returns the next message for this surface. + * + * Message selection takes into account, + * - the message's surface, + * - how many times the message has been displayed already + * - whether or not the user has interacted with the message already. + * - the message eligibility, via JEXL triggers. + * + * If more than one message for this surface is eligible to be shown, then the + * first one to be encountered in [messages] list is returned. + */ + fun getNextMessage(surface: MessageSurfaceId, messages: List<Message>): Message? { + val availableMessages = messages + .filter { + it.surface == surface + } + .filter { + !it.isExpired && + !it.metadata.dismissed && + !it.metadata.pressed + } + return createMessagingHelper().use { + getNextMessage( + surface, + availableMessages, + setOf(), + it, + ) + } + } + + @Suppress("ReturnCount") + private fun getNextMessage( + surface: MessageSurfaceId, + availableMessages: List<Message>, + excluded: Set<String>, + helper: NimbusMessagingHelperInterface, + ): Message? { + val message = availableMessages + .filter { !excluded.contains(it.id) } + .firstOrNull { + try { + isMessageEligible(it, helper) + } catch (e: NimbusException) { + reportMalformedMessage(it.id) + false + } + } ?: return null + + // If this is an experimental message, but not a placebo, then just return the message. + if (!message.data.isControl) { + return message + } + + // This is a control message which we're definitely not going to show to anyone, + // however, we need to do the bookkeeping and as if we were. + // + // Since no one is going to see it, then we need to do it ourselves, here. + runBlocking { + onMessageDisplayed(message) + } + + // This is a control, so we need to either return the next message (there may not be one) + // or not display anything. + return when (getOnControlBehavior()) { + ControlMessageBehavior.SHOW_NEXT_MESSAGE -> + getNextMessage( + surface, + availableMessages, + excluded + message.id, + helper, + ) + + ControlMessageBehavior.SHOW_NONE -> null + } + } + + /** + * Record the time and optional [bootIdentifier] of the display of the given message. + * + * If the message is part of an experiment, then record an exposure event for that + * experiment. + * + * This is determined by the value in the [message.data.experiment] property. + */ + suspend fun onMessageDisplayed(message: Message, bootIdentifier: String? = null): Message { + // Record an exposure event if this is an experimental message. + val slug = message.data.experiment + if (slug != null) { + // We know that it's experimental, and we know which experiment it came from. + messagingFeature.recordExperimentExposure(slug) + } else if (message.data.isControl) { + // It's not experimental, but it is a control. This is obviously malformed. + reportMalformedMessage(message.id) + } + + // Now update the display counts. + val updatedMetadata = message.metadata.copy( + displayCount = message.metadata.displayCount + 1, + lastTimeShown = now(), + latestBootIdentifier = bootIdentifier, + ) + val nextMessage = message.copy( + metadata = updatedMetadata, + ) + updateMetadata(nextMessage.metadata) + return nextMessage + } + + /** + * Returns a pair of uuid and valid action for the provided [message]. + * + * The message's action-params are appended as query parameters to the action URI, + * URI encoding the values as it goes. + * + * Uses Nimbus' targeting attributes to do basic string interpolation. + * + * e.g. + * `https://example.com/{locale}/whats-new.html?version={app_version}` + * + * If the string `{uuid}` is detected in the [message]'s action, then it is + * replaced with a random UUID. This is returned as the first value of the returned + * [Pair]. + * + * The fully resolved (with all substitutions) action is returned as the second value + * of the [Pair]. + */ + internal fun generateUuidAndFormatMessage(message: Message): Pair<String?, String> = + createMessagingHelper().use { helper -> + generateUuidAndFormatMessage(message, helper) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun generateUuidAndFormatMessage( + message: Message, + helper: NimbusMessagingHelperInterface, + ): Pair<String?, String> { + // The message action is part or all of a valid URL, likely to be a deeplink. + // If it's an internal deeplink, we don't yet know the scheme, so + // we'll prepend it later: these should just have a :// prefix right now. + // e.g. market://details?id=org.mozilla.blah or ://open + + // We need to construct it from the parts coming in from the message: + // the message.action, the message.actionParams and the attribute context. + // + // Any part of the action may have string params taken from the attribute + // context, and a special {uuid}. + // e.g. market://details?id={app_id} becomes market://details?id=org.mozilla.blah + // If there is a {uuid}, we want to create a uuid for later usage, i.e. recording in Glean + var uuid: String? = null + fun formatWithUuid(string: String): String { + if (uuid == null) { + uuid = helper.getUuid(string) + } + return helper.stringFormat(string, uuid) + } + + // We also want to do any string substitutions e.g. locale + // or UUID for the action. + val action = formatWithUuid(message.action) + + // Now we use a string builder to add the actionParams as query params to the URL. + val sb = StringBuilder(action) + + // Before the first query parameter is a `?`, and subsequent ones are `&`. + // The action may already have a query parameter. + var separator = if (action.contains('?')) { + '&' + } else { + '?' + } + + for ((queryParamName, queryParamValue) in message.data.actionParams) { + val v = formatWithUuid(queryParamValue) + sb + .append(separator) + .append(queryParamName) + .append('=') + .append(Uri.encode(v)) + + separator = '&' + } + + return uuid to sb.toString() + } + + /** + * Updated the provided [metadata] in the storage. + */ + suspend fun updateMetadata(metadata: Message.Metadata) { + metadataStorage.updateMetadata(metadata) + } + + @VisibleForTesting + internal fun sanitizeAction( + unsafeAction: String, + nimbusActions: Map<String, String>, + ): String? = nimbusActions[unsafeAction] + + @VisibleForTesting + internal fun sanitizeTriggers( + unsafeTriggers: List<String>, + nimbusTriggers: Map<String, String>, + ): List<String>? = + unsafeTriggers.map { + val safeTrigger = nimbusTriggers[it] + if (safeTrigger.isNullOrBlank() || safeTrigger.isEmpty()) { + return null + } + safeTrigger + } + + @VisibleForTesting + internal fun sanitizeStyle( + unsafeStyle: String, + nimbusStyles: Map<String, StyleData>, + ): StyleData? = nimbusStyles[unsafeStyle] + + /** + * Return true if the message passed as a parameter is eligible + * + * Aimed to be used from tests only, but currently public because some tests inside Fenix need + * it. This should be set as internal when this bug is fixed: + * https://bugzilla.mozilla.org/show_bug.cgi?id=1823472 + */ + @VisibleForTesting + fun isMessageEligible( + message: Message, + helper: NimbusMessagingHelperInterface, + ): Boolean { + return message.triggerIfAll.all { condition -> + evalJexl(message, helper, condition) + } && !message.excludeIfAny.any { condition -> + evalJexl(message, helper, condition) + } + } + + private fun evalJexl( + message: Message, + helper: NimbusMessagingHelperInterface, + condition: String, + ): Boolean = + try { + if (malFormedMap.containsKey(condition)) { + throw NimbusException.EvaluationException(condition) + } + helper.evalJexl(condition) + } catch (e: NimbusException) { + malFormedMap[condition] = message.id + logger.info("Unable to evaluate ${message.id} trigger: $condition") + throw NimbusException.EvaluationException(condition) + } + + @VisibleForTesting + internal fun getOnControlBehavior(): ControlMessageBehavior = messagingFeature.value().onControl + + private suspend fun addMetadata(id: String): Message.Metadata { + return metadataStorage.addMetadata( + Message.Metadata( + id = id, + ), + ) + } +} + +/** + * A helper method to safely destroy the message helper after use. + */ +fun <R> NimbusMessagingHelperInterface.use(block: (NimbusMessagingHelperInterface) -> R) = + block(this).also { + this.destroy() + } diff --git a/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/OnDiskMessageMetadataStorage.kt b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/OnDiskMessageMetadataStorage.kt new file mode 100644 index 0000000000..c31e7e968e --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/messaging/OnDiskMessageMetadataStorage.kt @@ -0,0 +1,96 @@ +/* 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.nimbus.messaging + +import android.content.Context +import android.util.AtomicFile +import androidx.annotation.VisibleForTesting +import mozilla.components.support.ktx.util.readAndDeserialize +import mozilla.components.support.ktx.util.writeString +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +internal const val FILE_NAME = "nimbus_messages_metadata.json" + +/** + * A storage that persists [Message.Metadata] into disk. + */ +class OnDiskMessageMetadataStorage( + private val context: Context, +) : MessageMetadataStorage { + private val diskCacheLock = Any() + + @VisibleForTesting + internal var metadataMap: MutableMap<String, Message.Metadata> = hashMapOf() + + override suspend fun getMetadata(): Map<String, Message.Metadata> { + if (metadataMap.isEmpty()) { + metadataMap = readFromDisk().toMutableMap() + } + return metadataMap + } + + override suspend fun addMetadata(metadata: Message.Metadata): Message.Metadata { + metadataMap[metadata.id] = metadata + writeToDisk() + return metadata + } + + override suspend fun updateMetadata(metadata: Message.Metadata) { + addMetadata(metadata) + } + + @VisibleForTesting + internal fun readFromDisk(): Map<String, Message.Metadata> { + synchronized(diskCacheLock) { + return getFile().readAndDeserialize { + JSONArray(it).toMetadataMap() + } ?: emptyMap() + } + } + + @VisibleForTesting + internal fun writeToDisk() { + synchronized(diskCacheLock) { + val json = metadataMap.values.toList().fold("") { acc, next -> + if (acc.isEmpty()) { + next.toJson() + } else { + "$acc,${next.toJson()}" + } + } + getFile().writeString { "[$json]" } + } + } + + private fun getFile(): AtomicFile { + return AtomicFile(File(context.filesDir, FILE_NAME)) + } +} + +internal fun JSONArray.toMetadataMap(): Map<String, Message.Metadata> { + return (0 until length()).map { index -> + getJSONObject(index).toMetadata() + }.associateBy { + it.id + } +} + +@Suppress("MaxLineLength") // To avoid adding any extra space to the string. +internal fun Message.Metadata.toJson(): String { + return """{"id":"$id","displayCount":$displayCount,"pressed":$pressed,"dismissed":$dismissed,"lastTimeShown":$lastTimeShown,"latestBootIdentifier":"$latestBootIdentifier"}""" +} + +internal fun JSONObject.toMetadata(): Message.Metadata { + return Message.Metadata( + id = optString("id"), + displayCount = optInt("displayCount"), + pressed = optBoolean("pressed"), + dismissed = optBoolean("dismissed"), + lastTimeShown = optLong("lastTimeShown"), + latestBootIdentifier = optString("latestBootIdentifier"), + ) +} diff --git a/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusBranchAdapter.kt b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusBranchAdapter.kt new file mode 100644 index 0000000000..cfa3ddd94a --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusBranchAdapter.kt @@ -0,0 +1,98 @@ +/* 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.nimbus.ui + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.service.nimbus.R +import org.mozilla.experiments.nimbus.Branch + +/** + * An adapter for displaying a experimental branch for a Nimbus experiment. + * + * @param nimbusBranchesDelegate An instance of [NimbusBranchesAdapterDelegate] that provides + * methods for handling the Nimbus branch items. + */ +class NimbusBranchAdapter( + private val nimbusBranchesDelegate: NimbusBranchesAdapterDelegate, +) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { + + // The list of [Branch]s to display. + private var branches: List<Branch> = emptyList() + + // The selected [Branch] slug to highlight. + private var selectedBranch: String = "" + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): RecyclerView.ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.mozac_service_nimbus_branch_item, parent, false) + val selectedIconView: ImageView = view.findViewById(R.id.selected_icon) + val titleView: TextView = view.findViewById(R.id.nimbus_branch_name) + val summaryView: TextView = view.findViewById(R.id.nimbus_branch_description) + + return NimbusBranchItemViewHolder( + view, + nimbusBranchesDelegate, + selectedIconView, + titleView, + summaryView, + ) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + holder as NimbusBranchItemViewHolder + holder.bind(branches[position], selectedBranch) + } + + override fun getItemCount(): Int = branches.size + + /** + * Updates the list of [Branch]s and the selected branch that are displayed. + * + * @param branches The list of [Branch]s to display. + * @param selectedBranch The [Branch] slug to highlight. + */ + fun updateData(branches: List<Branch>, selectedBranch: String) { + val diffUtil = DiffUtil.calculateDiff( + NimbusBranchesDiffUtil( + oldBranches = this.branches, + newBranches = branches, + oldSelectedBranch = this.selectedBranch, + newSelectedBranch = selectedBranch, + ), + ) + + this.branches = branches + this.selectedBranch = selectedBranch + + diffUtil.dispatchUpdatesTo(this) + } +} + +internal class NimbusBranchesDiffUtil( + private val oldBranches: List<Branch>, + private val newBranches: List<Branch>, + private val oldSelectedBranch: String, + private val newSelectedBranch: String, +) : DiffUtil.Callback() { + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + oldBranches[oldItemPosition].slug == newBranches[newItemPosition].slug && + oldSelectedBranch == newSelectedBranch + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = + oldBranches[oldItemPosition] == newBranches[newItemPosition] + + override fun getOldListSize(): Int = oldBranches.size + + override fun getNewListSize(): Int = newBranches.size +} diff --git a/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusBranchItemViewHolder.kt b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusBranchItemViewHolder.kt new file mode 100644 index 0000000000..69cdc7f1cc --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusBranchItemViewHolder.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.nimbus.ui + +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import org.mozilla.experiments.nimbus.Branch + +/** + * A view holder for displaying a branch of a Nimbus experiment. + */ +class NimbusBranchItemViewHolder( + view: View, + private val nimbusBranchesDelegate: NimbusBranchesAdapterDelegate, + private val selectedIconView: ImageView, + private val titleView: TextView, + private val summaryView: TextView, +) : RecyclerView.ViewHolder(view) { + + internal fun bind(branch: Branch, selectedBranch: String) { + selectedIconView.isVisible = selectedBranch == branch.slug + titleView.text = branch.slug + summaryView.text = branch.slug + + itemView.setOnClickListener { + nimbusBranchesDelegate.onBranchItemClicked(branch) + } + } +} diff --git a/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusBranchesAdapterDelegate.kt b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusBranchesAdapterDelegate.kt new file mode 100644 index 0000000000..04a79dfff4 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusBranchesAdapterDelegate.kt @@ -0,0 +1,19 @@ +/* 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.nimbus.ui + +import org.mozilla.experiments.nimbus.Branch + +/** + * Provides method for handling the branch items in the Nimbus branches view. + */ +interface NimbusBranchesAdapterDelegate { + /** + * Handler for when a branch item is clicked. + * + * @param branch The [Branch] that was clicked. + */ + fun onBranchItemClicked(branch: Branch) = Unit +} diff --git a/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusExperimentAdapter.kt b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusExperimentAdapter.kt new file mode 100644 index 0000000000..848bacb7df --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusExperimentAdapter.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.nimbus.ui + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import mozilla.components.service.nimbus.R +import org.mozilla.experiments.nimbus.AvailableExperiment + +/** + * An adapter for displaying nimbus experiment items. + */ +class NimbusExperimentAdapter( + private val nimbusExperimentsDelegate: NimbusExperimentsAdapterDelegate, + experiments: List<AvailableExperiment>, +) : ListAdapter<AvailableExperiment, NimbusExperimentItemViewHolder>(DiffCallback) { + + init { + submitList(experiments) + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): NimbusExperimentItemViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.mozac_service_nimbus_experiment_item, parent, false) + val titleView: TextView = view.findViewById(R.id.nimbus_experiment_name) + val summaryView: TextView = view.findViewById(R.id.nimbus_experiment_description) + return NimbusExperimentItemViewHolder(view, nimbusExperimentsDelegate, titleView, summaryView) + } + + override fun onBindViewHolder(holder: NimbusExperimentItemViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + private object DiffCallback : DiffUtil.ItemCallback<AvailableExperiment>() { + override fun areContentsTheSame(oldItem: AvailableExperiment, newItem: AvailableExperiment) = + oldItem == newItem + + override fun areItemsTheSame(oldItem: AvailableExperiment, newItem: AvailableExperiment) = + oldItem.slug == newItem.slug + } +} diff --git a/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusExperimentItemViewHolder.kt b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusExperimentItemViewHolder.kt new file mode 100644 index 0000000000..2d36208313 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusExperimentItemViewHolder.kt @@ -0,0 +1,30 @@ +/* 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.nimbus.ui + +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import org.mozilla.experiments.nimbus.AvailableExperiment + +/** + * A view holder for displaying Nimbus experiment items. + */ +class NimbusExperimentItemViewHolder( + view: View, + private val nimbusExperimentsDelegate: NimbusExperimentsAdapterDelegate, + private val titleView: TextView, + private val summaryView: TextView, +) : RecyclerView.ViewHolder(view) { + + internal fun bind(experiment: AvailableExperiment) { + titleView.text = experiment.userFacingName + summaryView.text = experiment.userFacingDescription + + itemView.setOnClickListener { + nimbusExperimentsDelegate.onExperimentItemClicked(experiment) + } + } +} diff --git a/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusExperimentsAdapterDelegate.kt b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusExperimentsAdapterDelegate.kt new file mode 100644 index 0000000000..e51578aaf8 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/java/mozilla/components/service/nimbus/ui/NimbusExperimentsAdapterDelegate.kt @@ -0,0 +1,19 @@ +/* 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.nimbus.ui + +import org.mozilla.experiments.nimbus.AvailableExperiment + +/** + * Provides methods for handling the experiment items in the Nimbus experiments manager. + */ +interface NimbusExperimentsAdapterDelegate { + /** + * Handler for when an experiment item is clicked. + * + * @param experiment The [AvailableExperiment] that was clicked. + */ + fun onExperimentItemClicked(experiment: AvailableExperiment) = Unit +} diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/layout/mozac_service_nimbus_branch_item.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/layout/mozac_service_nimbus_branch_item.xml new file mode 100644 index 0000000000..1567c38647 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/layout/mozac_service_nimbus_branch_item.xml @@ -0,0 +1,58 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> + +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="48dp"> + + <ImageView + android:id="@+id/selected_icon" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + android:importantForAccessibility="no" + android:visibility="visible" + app:tint="?android:attr/textColorPrimary" + app:srcCompat="@drawable/mozac_ic_checkmark_24" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <TextView + android:id="@+id/nimbus_branch_name" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="32dp" + android:layout_marginEnd="16dp" + android:textAlignment="viewStart" + android:textSize="16sp" + app:layout_goneMarginStart="72dp" + app:layout_constraintBottom_toTopOf="@id/nimbus_branch_description" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@id/selected_icon" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintVertical_chainStyle="packed" + tools:text="Control Branch" /> + + <TextView + android:id="@+id/nimbus_branch_description" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="32dp" + android:layout_marginEnd="16dp" + android:textAlignment="viewStart" + android:textSize="12sp" + android:visibility="visible" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_goneMarginStart="72dp" + app:layout_constraintStart_toEndOf="@id/selected_icon" + app:layout_constraintTop_toBottomOf="@id/nimbus_branch_name" + app:layout_constraintVertical_chainStyle="packed" + tools:text="This is control." /> + +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/layout/mozac_service_nimbus_experiment_details.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/layout/mozac_service_nimbus_experiment_details.xml new file mode 100644 index 0000000000..fb1aaf9950 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/layout/mozac_service_nimbus_experiment_details.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> + +<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/nimbus_experiment_branches_list" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_marginTop="2dp" /> + +</androidx.coordinatorlayout.widget.CoordinatorLayout> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/layout/mozac_service_nimbus_experiment_item.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/layout/mozac_service_nimbus_experiment_item.xml new file mode 100644 index 0000000000..ec4e3a9a6f --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/layout/mozac_service_nimbus_experiment_item.xml @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> + +<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/nimbus_experiment_item" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?android:attr/selectableItemBackground" + android:orientation="horizontal" + android:paddingStart="16dp" + android:paddingEnd="16dp"> + + <LinearLayout + android:id="@+id/nimbus_experiment_details_container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:layout_marginTop="8dp" + android:layout_marginBottom="8dp" + android:layout_marginEnd="8dp" + android:orientation="vertical" + android:paddingStart="8dp" + android:paddingTop="8dp" + android:paddingEnd="8dp" + android:paddingBottom="8dp"> + + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="2dp" + android:orientation="horizontal"> + + <TextView + android:id="@+id/nimbus_experiment_name" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical" + android:layout_weight="1" + android:ellipsize="end" + android:maxLines="1" + android:textSize="16sp" + tools:text="Test CFR Experiment" /> + + </LinearLayout> + + <TextView + android:id="@+id/nimbus_experiment_description" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:textSize="14sp" + tools:text="If we do this/build this/create this change in the experiment for these users, then we will see this outcome." /> + + </LinearLayout> +</RelativeLayout> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/layout/mozac_service_nimbus_experiments.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/layout/mozac_service_nimbus_experiments.xml new file mode 100644 index 0000000000..511d4332db --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/layout/mozac_service_nimbus_experiments.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> + +<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/nimbus_experiments_list" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_marginTop="2dp" /> + + <TextView + android:id="@+id/nimbus_experiments_empty_message" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="4dp" + android:layout_gravity="center" + android:text="@string/mozac_service_nimbus_no_experiments" + android:textAlignment="center" + android:textColor="?android:attr/textColorPrimary" + android:textSize="16sp" + android:visibility="gone"/> + +</androidx.coordinatorlayout.widget.CoordinatorLayout> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-am/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-am/strings.xml new file mode 100644 index 0000000000..c471842b2c --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-am/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">እዚህ ምንም ሙከራዎች የሉም</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-ar/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ar/strings.xml new file mode 100644 index 0000000000..a270594e84 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ar/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">ما من تجارب هنا</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-ast/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ast/strings.xml new file mode 100644 index 0000000000..3cb6071229 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ast/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Nun hai nengún esperimentu</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-azb/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-azb/strings.xml new file mode 100644 index 0000000000..47a749951b --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-azb/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">بورادا تجروبه یوخدور</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-ban/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ban/strings.xml new file mode 100644 index 0000000000..b66dfa3260 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ban/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Tén wénten ékspérimen driki</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-be/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-be/strings.xml new file mode 100644 index 0000000000..2710a62d24 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-be/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Эксперыментаў няма</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-bg/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-bg/strings.xml new file mode 100644 index 0000000000..56c3405ebe --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-bg/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Няма налични експерименти</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-br/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-br/strings.xml new file mode 100644 index 0000000000..573428755c --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-br/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Tamm arnod ebet amañ</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-bs/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-bs/strings.xml new file mode 100644 index 0000000000..f5db38489b --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-bs/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Ovdje nema eksperimenata</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-ca/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ca/strings.xml new file mode 100644 index 0000000000..e435d23410 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ca/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">No hi ha cap experiment</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-cak/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-cak/strings.xml new file mode 100644 index 0000000000..a5372c21e2 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-cak/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Majun tojtob\'enel wawe\'</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-ceb/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ceb/strings.xml new file mode 100644 index 0000000000..b2f67da782 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ceb/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Walay mga eksperimento dinhi</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-ckb/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ckb/strings.xml new file mode 100644 index 0000000000..b47389014a --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ckb/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">هیچ تاقیکردنەوەیەک نیە لێرە</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-co/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-co/strings.xml new file mode 100644 index 0000000000..4c2c008bb7 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-co/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Nisuna sperimentazione quì</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-cs/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-cs/strings.xml new file mode 100644 index 0000000000..83086a59cb --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-cs/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Žádné experimenty</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-cy/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-cy/strings.xml new file mode 100644 index 0000000000..cc489b5882 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-cy/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Dim arbrofion yma</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-da/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-da/strings.xml new file mode 100644 index 0000000000..df0cb7f34c --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-da/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Ingen eksperimenter her</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-de/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-de/strings.xml new file mode 100644 index 0000000000..16a327461d --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-de/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Keine Experimente vorhanden</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-dsb/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-dsb/strings.xml new file mode 100644 index 0000000000..7f70d7c37d --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-dsb/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Žedne eksperimenty how</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-el/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-el/strings.xml new file mode 100644 index 0000000000..5395d1dc98 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-el/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Δεν υπάρχουν πειράματα</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-en-rCA/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-en-rCA/strings.xml new file mode 100644 index 0000000000..3125bfbf4a --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-en-rCA/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">No experiments here</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-en-rGB/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-en-rGB/strings.xml new file mode 100644 index 0000000000..3125bfbf4a --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-en-rGB/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">No experiments here</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-eo/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-eo/strings.xml new file mode 100644 index 0000000000..d01b12d0be --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-eo/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Ne estas eksperimentoj ĉi tie</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-es-rAR/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-es-rAR/strings.xml new file mode 100644 index 0000000000..f1ae0a6f22 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-es-rAR/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">No hay experimentos aquí</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-es-rCL/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-es-rCL/strings.xml new file mode 100644 index 0000000000..f1ae0a6f22 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-es-rCL/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">No hay experimentos aquí</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-es-rES/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-es-rES/strings.xml new file mode 100644 index 0000000000..f1ae0a6f22 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-es-rES/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">No hay experimentos aquí</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-es-rMX/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-es-rMX/strings.xml new file mode 100644 index 0000000000..f1ae0a6f22 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-es-rMX/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">No hay experimentos aquí</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-es/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-es/strings.xml new file mode 100644 index 0000000000..f1ae0a6f22 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-es/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">No hay experimentos aquí</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-et/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-et/strings.xml new file mode 100644 index 0000000000..e2cc4b13a1 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-et/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Siin pole eksperimente</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-eu/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-eu/strings.xml new file mode 100644 index 0000000000..ff662a5acb --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-eu/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Esperimenturik ez hemen</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-fa/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-fa/strings.xml new file mode 100644 index 0000000000..fc543f9b6c --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-fa/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">هیچ آزمایشی اینجا نیست</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-fi/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-fi/strings.xml new file mode 100644 index 0000000000..7ede19351d --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-fi/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Täältä ei löydy kokeiluja</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-fr/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000000..1127156a3c --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-fr/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Aucune expérience ici</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-fur/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-fur/strings.xml new file mode 100644 index 0000000000..48bc258f77 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-fur/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Nissun esperiment disponibil</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-fy-rNL/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-fy-rNL/strings.xml new file mode 100644 index 0000000000..702b8c2edf --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-fy-rNL/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Gjin eksperiminten hjir</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-gd/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-gd/strings.xml new file mode 100644 index 0000000000..ffce34e92b --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-gd/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Chan eil deuchainn an-seo</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-gl/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-gl/strings.xml new file mode 100644 index 0000000000..9451c74fd7 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-gl/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Aquí non hai experimentos</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-gn/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-gn/strings.xml new file mode 100644 index 0000000000..ac819bbcc5 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-gn/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Ndaipóri tembiapopyahu ápe</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-hi-rIN/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-hi-rIN/strings.xml new file mode 100644 index 0000000000..6c52400c4d --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-hi-rIN/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">यहां कोई प्रयोग नहीं</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-hr/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-hr/strings.xml new file mode 100644 index 0000000000..f5db38489b --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-hr/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Ovdje nema eksperimenata</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-hsb/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-hsb/strings.xml new file mode 100644 index 0000000000..8b51206a10 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-hsb/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Žane eksperimenty tu</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-hu/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000000..a2764a06be --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-hu/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Itt nincsenek kísérletek</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-hy-rAM/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-hy-rAM/strings.xml new file mode 100644 index 0000000000..ef010a3518 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-hy-rAM/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Այստեղ փորձեր չկան</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-ia/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ia/strings.xml new file mode 100644 index 0000000000..2e94be9b97 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ia/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Nulle experimentos hic</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-in/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-in/strings.xml new file mode 100644 index 0000000000..372b741190 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-in/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Tidak ada eksperimen di sini</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-is/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-is/strings.xml new file mode 100644 index 0000000000..b9522b4049 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-is/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Engar tilraunir hér</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-it/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-it/strings.xml new file mode 100644 index 0000000000..27e9558e43 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-it/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Nessun esperimento disponibile</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-iw/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-iw/strings.xml new file mode 100644 index 0000000000..87c8b04455 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-iw/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">אין כאן ניסויים</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-ja/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000000..3a9af1f6a6 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ja/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">実験は行われていません</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-ka/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ka/strings.xml new file mode 100644 index 0000000000..b60345950e --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ka/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">კვლევები არაა</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-kaa/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-kaa/strings.xml new file mode 100644 index 0000000000..4e653f3d12 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-kaa/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Bul jerde tájiriybeler joq</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-kab/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-kab/strings.xml new file mode 100644 index 0000000000..614ee2c945 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-kab/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Ulac tirma da</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-kk/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-kk/strings.xml new file mode 100644 index 0000000000..054b3d3f74 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-kk/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Мұнда эксперименттер жоқ</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-kmr/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-kmr/strings.xml new file mode 100644 index 0000000000..bcdbb78082 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-kmr/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Niha cerebe tune ye</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-kn/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-kn/strings.xml new file mode 100644 index 0000000000..596201714a --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-kn/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">ಇಲ್ಲಿ ಯಾವುದೇ ಪ್ರಯೋಗಗಳಿಲ್ಲ</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-ko/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ko/strings.xml new file mode 100644 index 0000000000..19b797951c --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ko/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">실험 없음</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-lo/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-lo/strings.xml new file mode 100644 index 0000000000..928dfcb6fa --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-lo/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">ບໍ່ມີການທົດລອງຢູ່ທີ່ນີ້</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-lt/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-lt/strings.xml new file mode 100644 index 0000000000..0f1c38f2e2 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-lt/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Eksperimentų čia nėra</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-my/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-my/strings.xml new file mode 100644 index 0000000000..328f26e311 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-my/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">ဤ နေရာတွင် စမ်းသပ်မှုများ မရှိပါ။</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-nb-rNO/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-nb-rNO/strings.xml new file mode 100644 index 0000000000..7723d719ce --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-nb-rNO/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Det finnes ingen eksperimenter</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-ne-rNP/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ne-rNP/strings.xml new file mode 100644 index 0000000000..36bd5f58b6 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ne-rNP/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">यहाँ कुनै प्रयोगहरु छैनन्</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-nl/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000000..20e0a03446 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-nl/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Geen experimenten hier</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-nn-rNO/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-nn-rNO/strings.xml new file mode 100644 index 0000000000..76b520c14f --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-nn-rNO/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Det finst ingen eksperiment her</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-oc/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-oc/strings.xml new file mode 100644 index 0000000000..0363dc3931 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-oc/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Cap d’experimentacion aquí</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-pa-rIN/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-pa-rIN/strings.xml new file mode 100644 index 0000000000..5af8b944e8 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-pa-rIN/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">ਕੋਈ ਤਜਰਬਾ ਨਹੀਂ ਹੈ</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-pa-rPK/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-pa-rPK/strings.xml new file mode 100644 index 0000000000..143af8eeb3 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-pa-rPK/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">اِتھے کوئی تجرںے نہیں ہن</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-pl/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000000..b28e5e3c5b --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-pl/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Nie ma tu żadnych eksperymentów</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-pt-rBR/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000000..6a4bf369ec --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Nenhum experimento aqui</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-pt-rPT/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-pt-rPT/strings.xml new file mode 100644 index 0000000000..9cdcb7a381 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-pt-rPT/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Sem experiências disponíveis</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-rm/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-rm/strings.xml new file mode 100644 index 0000000000..2dbff8d044 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-rm/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Nagins experiments disponibels</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-ru/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000000..2747339cc6 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ru/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Здесь нет экспериментов</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-sat/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-sat/strings.xml new file mode 100644 index 0000000000..d73005300a --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-sat/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">ᱪᱮᱫ ᱦᱚᱸ ᱮᱠᱥᱯᱮᱨᱤᱢᱮᱱᱴ ᱱᱚᱰᱮ ᱵᱟᱝ ᱦᱩᱭᱩᱜ ᱠᱟᱱᱟ ᱾</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-sc/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-sc/strings.xml new file mode 100644 index 0000000000..2d7106f6b8 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-sc/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Nissunu esperimentu inoghe</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-si/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-si/strings.xml new file mode 100644 index 0000000000..91458c666c --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-si/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">මෙහි අත්හදා බැලීම් නැත</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-sk/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-sk/strings.xml new file mode 100644 index 0000000000..cfe2804573 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-sk/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Žiadne experimenty</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-skr/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-skr/strings.xml new file mode 100644 index 0000000000..0135c35baf --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-skr/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">اتھاں کوئی تجربے کائنی</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-sl/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000000..bf11b3070a --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-sl/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Tu ni poskusov</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-sq/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-sq/strings.xml new file mode 100644 index 0000000000..bfa8e35c39 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-sq/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">S’ka eksperimente këtu</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-sr/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-sr/strings.xml new file mode 100644 index 0000000000..a65ed53580 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-sr/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Овде нема експеримената</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-su/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-su/strings.xml new file mode 100644 index 0000000000..4081d44c33 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-su/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Taya uji coba di dieu</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-sv-rSE/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-sv-rSE/strings.xml new file mode 100644 index 0000000000..903f10c246 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-sv-rSE/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Det finns inga experiment</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-te/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-te/strings.xml new file mode 100644 index 0000000000..eb8b0f6b49 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-te/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">ఇక్కడ ప్రయోగాలేమీ లేవు</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-tg/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-tg/strings.xml new file mode 100644 index 0000000000..7622d210f7 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-tg/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Дар ин ҷо ягон озмоиш нест</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-th/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-th/strings.xml new file mode 100644 index 0000000000..8b8ccb68cd --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-th/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">ไม่มีการทดลองที่นี่</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-tl/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-tl/strings.xml new file mode 100644 index 0000000000..42f0f679cd --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-tl/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Walang mga eksperimento dito</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-tr/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000000..d32b690f73 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-tr/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Şu anda deney yok</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-trs/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-trs/strings.xml new file mode 100644 index 0000000000..0addf2cd36 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-trs/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Nitāj ēkspērimênto hua hiūj nan</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-tt/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-tt/strings.xml new file mode 100644 index 0000000000..f2edcfa4b8 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-tt/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Монда экспериментлар юк</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-ug/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ug/strings.xml new file mode 100644 index 0000000000..1ac7a88698 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ug/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">بۇ يەردە سىناق يوق</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-uk/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000000..098db371e9 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-uk/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Досліджень немає</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-ur/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ur/strings.xml new file mode 100644 index 0000000000..3a58ee296d --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-ur/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">یہاں کوئی تجربات نہیں ہیں</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-uz/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-uz/strings.xml new file mode 100644 index 0000000000..b13185d87b --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-uz/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Bu erda hech qanday tajriba yoʻq</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-vi/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000000..363f692689 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-vi/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Hiện không có thử nghiệm nào ở đây</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-yo/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-yo/strings.xml new file mode 100644 index 0000000000..3ae1b05c96 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-yo/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">Kò sí ìṣàyẹ̀wò níbí</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-zh-rCN/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000000..2ca8dea7b9 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">暂无实验项</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values-zh-rTW/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000000..9d7bae1ef2 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">目前沒有實驗項目</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/main/res/values/strings.xml b/mobile/android/android-components/components/service/nimbus/src/main/res/values/strings.xml new file mode 100644 index 0000000000..ae3ee392d2 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> +<resources> + <!-- Text displayed when there are no experiments to be shown --> + <string name="mozac_service_nimbus_no_experiments">No experiments here</string> +</resources> diff --git a/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/NimbusTest.kt b/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/NimbusTest.kt new file mode 100644 index 0000000000..64ea914a78 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/NimbusTest.kt @@ -0,0 +1,47 @@ +/* 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.nimbus + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.experiments.nimbus.NimbusAppInfo +import org.mozilla.experiments.nimbus.NimbusDelegate +import org.mozilla.experiments.nimbus.NimbusInterface + +@RunWith(AndroidJUnit4::class) +class NimbusTest { + private val context: Context + get() = ApplicationProvider.getApplicationContext() + + private val appInfo = NimbusAppInfo( + appName = "NimbusUnitTest", + channel = "test", + ) + + @Test + fun `Nimbus disabled and enabled can have observers registered on it`() { + val enabled: NimbusApi = Nimbus(context, appInfo, listOf(), null, delegate = NimbusDelegate.default()) + val disabled: NimbusApi = NimbusDisabled(context) + + val observer = object : NimbusInterface.Observer {} + + enabled.register(observer) + disabled.register(observer) + } + + @Test + fun `NimbusDisabled is empty`() { + val nimbus: NimbusApi = NimbusDisabled(context) + nimbus.fetchExperiments() + nimbus.applyPendingExperiments() + assertTrue("getActiveExperiments should be empty", nimbus.getActiveExperiments().isEmpty()) + assertEquals(null, nimbus.getExperimentBranch("test-experiment")) + } +} diff --git a/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/messaging/NimbusMessagingControllerTest.kt b/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/messaging/NimbusMessagingControllerTest.kt new file mode 100644 index 0000000000..00cdb14d6a --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/messaging/NimbusMessagingControllerTest.kt @@ -0,0 +1,276 @@ +/* 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.nimbus.messaging + +import android.content.Intent +import androidx.core.net.toUri +import kotlinx.coroutines.test.runTest +import mozilla.components.service.glean.testing.GleanTestRule +import mozilla.components.support.test.any +import mozilla.components.support.test.eq +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mozilla.experiments.nimbus.NullVariables +import org.robolectric.RobolectricTestRunner +import java.util.UUID +import mozilla.components.service.nimbus.GleanMetrics.Messaging as GleanMessaging + +@RunWith(RobolectricTestRunner::class) +class NimbusMessagingControllerTest { + + private val storage: NimbusMessagingStorage = mock(NimbusMessagingStorage::class.java) + + @get:Rule + val gleanTestRule = GleanTestRule(testContext) + + private val coroutinesTestRule = MainCoroutineRule() + private val coroutineScope = coroutinesTestRule.scope + + private val deepLinkScheme = "deepLinkScheme" + private val controller = NimbusMessagingController(storage, deepLinkScheme) + + @Before + fun setup() { + NullVariables.instance.setContext(testContext) + } + + @Test + fun `GIVEN message not expired WHEN calling onMessageDisplayed THEN record a messageShown event and update storage`() = + coroutineScope.runTest { + val message = createMessage("id-1", style = StyleData(maxDisplayCount = 2)) + val displayedMessage = createMessage("id-1", style = StyleData(maxDisplayCount = 2), displayCount = 1) + `when`(storage.onMessageDisplayed(eq(message), any())).thenReturn(displayedMessage) + + // Assert telemetry is initially null + assertNull(GleanMessaging.messageShown.testGetValue()) + assertNull(GleanMessaging.messageExpired.testGetValue()) + + controller.onMessageDisplayed(message) + + // Shown telemetry + assertNotNull(GleanMessaging.messageShown.testGetValue()) + val shownEvent = GleanMessaging.messageShown.testGetValue()!! + assertEquals(1, shownEvent.size) + assertEquals(message.id, shownEvent.single().extra!!["message_key"]) + + // Expired telemetry + assertNull(GleanMessaging.messageExpired.testGetValue()) + + verify(storage).onMessageDisplayed(eq(message), any()) + } + + @Test + fun `GIVEN message is expired WHEN calling onMessageDisplayed THEN record messageShown, messageExpired events and update storage`() = + coroutineScope.runTest { + val message = + createMessage("id-1", style = StyleData(maxDisplayCount = 1), displayCount = 0) + val displayedMessage = createMessage("id-1", style = StyleData(maxDisplayCount = 1), displayCount = 1) + `when`(storage.onMessageDisplayed(any(), any())).thenReturn(displayedMessage) + // Assert telemetry is initially null + assertNull(GleanMessaging.messageShown.testGetValue()) + assertNull(GleanMessaging.messageExpired.testGetValue()) + + controller.onMessageDisplayed(message) + + // Shown telemetry + assertNotNull(GleanMessaging.messageShown.testGetValue()) + val shownEvent = GleanMessaging.messageShown.testGetValue()!! + assertEquals(1, shownEvent.size) + assertEquals(message.id, shownEvent.single().extra!!["message_key"]) + + // Expired telemetry + assertNotNull(GleanMessaging.messageExpired.testGetValue()) + val expiredEvent = GleanMessaging.messageExpired.testGetValue()!! + assertEquals(1, expiredEvent.size) + assertEquals(message.id, expiredEvent.single().extra!!["message_key"]) + + verify(storage).onMessageDisplayed(message) + } + + @Test + fun `WHEN calling onMessageDismissed THEN record a messageDismissed event and update metadata`() = + coroutineScope.runTest { + val message = createMessage("id-1") + assertNull(GleanMessaging.messageDismissed.testGetValue()) + + controller.onMessageDismissed(message) + + assertNotNull(GleanMessaging.messageDismissed.testGetValue()) + val event = GleanMessaging.messageDismissed.testGetValue()!! + assertEquals(1, event.size) + assertEquals(message.id, event.single().extra!!["message_key"]) + + verify(storage).updateMetadata(message.metadata.copy(dismissed = true)) + } + + @Test + fun `GIVEN action is URL WHEN calling processMessageActionToUri THEN record a clicked telemetry event`() { + val message = createMessage("id-1") + + `when`(storage.generateUuidAndFormatMessage(message)) + .thenReturn(Pair(null, "://mock-uri")) + + // Assert telemetry is initially null + assertNull(GleanMessaging.messageClicked.testGetValue()) + + val expectedUri = "$deepLinkScheme://mock-uri".toUri() + + val actualUri = controller.processMessageActionToUri(message) + + // Updated telemetry + assertNotNull(GleanMessaging.messageClicked.testGetValue()) + val clickedEvent = GleanMessaging.messageClicked.testGetValue()!! + assertEquals(1, clickedEvent.size) + assertEquals(message.id, clickedEvent.single().extra!!["message_key"]) + + assertEquals(expectedUri, actualUri) + } + + @Test + fun `GIVEN a URL with a {uuid} WHEN calling processMessageActionToUri THEN record a clicked telemetry event`() { + val url = "http://mozilla.org?uuid={uuid}" + val message = createMessage("id-1", action = "://open", messageData = MessageData(actionParams = mapOf("url" to url))) + val uuid = UUID.randomUUID().toString() + `when`(storage.generateUuidAndFormatMessage(message)).thenReturn(Pair(uuid, "://mock-uri")) + + // Assert telemetry is initially null + assertNull(GleanMessaging.messageClicked.testGetValue()) + + val expectedUri = "$deepLinkScheme://mock-uri".toUri() + + val actualUri = controller.processMessageActionToUri(message) + + // Updated telemetry + val clickedEvents = GleanMessaging.messageClicked.testGetValue() + assertNotNull(clickedEvents) + val clickedEvent = clickedEvents!!.single() + assertEquals(message.id, clickedEvent.extra!!["message_key"]) + assertEquals(uuid, clickedEvent.extra!!["action_uuid"]) + + assertEquals(expectedUri, actualUri) + } + + @Test + fun `GIVEN action is deeplink WHEN calling processMessageActionToUri THEN return a deeplink URI`() { + val message = createMessage("id-1", action = "://a-deep-link") + `when`(storage.generateUuidAndFormatMessage(message)) + .thenReturn(Pair(null, message.action)) + + // Assert telemetry is initially null + assertNull(GleanMessaging.messageClicked.testGetValue()) + + val expectedUri = "$deepLinkScheme${message.action}".toUri() + val actualUri = controller.processMessageActionToUri(message) + + // Updated telemetry + assertNotNull(GleanMessaging.messageClicked.testGetValue()) + val clickedEvent = GleanMessaging.messageClicked.testGetValue()!! + assertEquals(1, clickedEvent.size) + assertEquals(message.id, clickedEvent.single().extra!!["message_key"]) + + assertEquals(expectedUri, actualUri) + } + + @Test + fun `GIVEN action unknown format WHEN calling processMessageActionToUri THEN return the action URI`() { + val message = createMessage("id-1", action = "unknown") + `when`(storage.generateUuidAndFormatMessage(message)) + .thenReturn(Pair(null, message.action)) + + // Assert telemetry is initially null + assertNull(GleanMessaging.messageClicked.testGetValue()) + + val expectedUri = message.action.toUri() + val actualUri = controller.processMessageActionToUri(message) + + // Updated telemetry + assertNotNull(GleanMessaging.messageClicked.testGetValue()) + val clickedEvent = GleanMessaging.messageClicked.testGetValue()!! + assertEquals(1, clickedEvent.size) + assertEquals(message.id, clickedEvent.single().extra!!["message_key"]) + + assertEquals(expectedUri, actualUri) + } + + @Test + fun `WHEN calling onMessageClicked THEN update stored metadata for message`() = + coroutineScope.runTest { + val message = createMessage("id-1") + assertFalse(message.metadata.pressed) + + controller.onMessageClicked(message) + + val updatedMetadata = message.metadata.copy(pressed = true) + verify(storage).updateMetadata(updatedMetadata) + } + + @Test + fun `WHEN getIntentForMessageAction is called THEN return a generated Intent with the processed Message action`() { + val message = createMessage("id-1", action = "unknown") + `when`(storage.generateUuidAndFormatMessage(message)) + .thenReturn(Pair(null, message.action)) + assertNull(GleanMessaging.messageClicked.testGetValue()) + + val actualIntent = controller.getIntentForMessage(message) + + // Updated telemetry + assertNotNull(GleanMessaging.messageClicked.testGetValue()) + val event = GleanMessaging.messageClicked.testGetValue()!! + assertEquals(1, event.size) + assertEquals(message.id, event.single().extra!!["message_key"]) + + // The processed Intent data + assertEquals(Intent.ACTION_VIEW, actualIntent.action) + val expectedUri = message.action.toUri() + assertEquals(expectedUri, actualIntent.data) + } + + @Test + fun `GIVEN stored messages contains a matching message WHEN calling getMessage THEN return the matching message`() = + coroutineScope.runTest { + val message1 = createMessage("1") + `when`(storage.getMessage(message1.id)).thenReturn(message1) + val actualMessage = controller.getMessage(message1.id) + + assertEquals(message1, actualMessage) + } + + @Test + fun `GIVEN stored messages doesn't contain a matching message WHEN calling getMessage THEN return null`() = + coroutineScope.runTest { + `when`(storage.getMessage("unknown id")).thenReturn(null) + val actualMessage = controller.getMessage("unknown id") + + assertNull(actualMessage) + } + + private fun createMessage( + id: String, + messageData: MessageData = MessageData(), + action: String = messageData.action, + style: StyleData = StyleData(), + displayCount: Int = 0, + ): Message = + Message( + id, + data = messageData, + style = style, + metadata = Message.Metadata(id, displayCount), + triggerIfAll = emptyList(), + excludeIfAny = emptyList(), + action = action, + ) +} diff --git a/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/messaging/NimbusMessagingStorageTest.kt b/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/messaging/NimbusMessagingStorageTest.kt new file mode 100644 index 0000000000..a5c17e1cb2 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/messaging/NimbusMessagingStorageTest.kt @@ -0,0 +1,927 @@ +/* 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.nimbus.messaging + +import android.net.Uri +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import mozilla.components.service.nimbus.messaging.ControlMessageBehavior.SHOW_NEXT_MESSAGE +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mozilla.experiments.nimbus.FeaturesInterface +import org.mozilla.experiments.nimbus.NimbusMessagingHelperInterface +import org.mozilla.experiments.nimbus.NimbusMessagingInterface +import org.mozilla.experiments.nimbus.NullVariables +import org.mozilla.experiments.nimbus.Res +import org.mozilla.experiments.nimbus.internal.FeatureHolder +import org.mozilla.experiments.nimbus.internal.NimbusException +import org.robolectric.RobolectricTestRunner +import java.util.UUID + +private const val MOCK_TIME_MILLIS = 1000L + +@RunWith(RobolectricTestRunner::class) +@kotlinx.coroutines.ExperimentalCoroutinesApi +class NimbusMessagingStorageTest { + @Mock private lateinit var metadataStorage: MessageMetadataStorage + + @Mock private lateinit var nimbus: NimbusMessagingInterface + + private lateinit var storage: NimbusMessagingStorage + private lateinit var messagingFeature: FeatureHolder<Messaging> + private var malformedWasReported = false + private var malformedMessageIds = mutableSetOf<String>() + private val reportMalformedMessage: (String) -> Unit = { + malformedMessageIds.add(it) + malformedWasReported = true + } + private lateinit var featuresInterface: FeaturesInterface + + private val displayOnceStyle = StyleData(maxDisplayCount = 1) + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + NullVariables.instance.setContext(testContext) + + val (messagingFeatureTmp, featuresInterfaceTmp) = createMessagingFeature() + + messagingFeature = spy(messagingFeatureTmp) + featuresInterface = featuresInterfaceTmp + + runBlocking { + `when`(metadataStorage.getMetadata()) + .thenReturn(mapOf("message-1" to Message.Metadata(id = "message-1"))) + + `when`(metadataStorage.addMetadata(any())) + .thenReturn(mock()) + } + + storage = NimbusMessagingStorage( + testContext, + metadataStorage, + reportMalformedMessage, + nimbus, + messagingFeature, + ) { MOCK_TIME_MILLIS } + + val helper: NimbusMessagingHelperInterface = mock() + `when`(helper.evalJexl(any())).thenReturn(true) + `when`(nimbus.createMessageHelper(any())).thenReturn(helper) + } + + @After + fun tearDown() { + malformedWasReported = false + malformedMessageIds.clear() + } + + @Test + fun `WHEN calling getCoenrollingFeatureIds THEN messaging is in that list`() { + assertTrue(FxNimbusMessaging.getCoenrollingFeatureIds().contains(MESSAGING_FEATURE_ID)) + } + + @Test + fun `WHEN calling getMessages THEN provide a list of available messages for a given surface`() = + runTest { + val homescreenMessage = storage.getMessages().first() + + assertEquals("message-1", homescreenMessage.id) + assertEquals("message-1", homescreenMessage.metadata.id) + + val notificationMessage = storage.getMessages().last() + assertEquals("message-2", notificationMessage.id) + } + + @Test + fun `WHEN calling getMessages THEN provide a list of sorted messages by priority`() = + runTest { + val messages = mapOf( + "low-message" to createMessageData(style = "low-priority"), + "high-message" to createMessageData(style = "high-priority"), + "medium-message" to createMessageData(style = "medium-priority"), + ) + val styles = mapOf( + "high-priority" to createStyle(priority = 100), + "medium-priority" to createStyle(priority = 50), + "low-priority" to createStyle(priority = 1), + ) + val (messagingFeature, _) = createMessagingFeature( + styles = styles, + messages = messages, + ) + + val storage = NimbusMessagingStorage( + testContext, + metadataStorage, + reportMalformedMessage, + nimbus, + messagingFeature, + ) + + val results = storage.getMessages() + + assertEquals("high-message", results[0].id) + assertEquals("medium-message", results[1].id) + assertEquals("low-message", results[2].id) + } + + @Test + fun `GIVEN pressed message WHEN calling getMessages THEN filter out the pressed message`() = + runTest { + val metadataList = mapOf( + "pressed-message" to Message.Metadata(id = "pressed-message", pressed = true), + "normal-message" to Message.Metadata(id = "normal-message", pressed = false), + ) + val messages = mapOf( + "pressed-message" to createMessageData(style = "high-priority"), + "normal-message" to createMessageData(style = "high-priority"), + ) + val styles = mapOf( + "high-priority" to createStyle(priority = 100), + ) + val metadataStorage: MessageMetadataStorage = mock() + val (messagingFeature, _) = createMessagingFeature( + styles = styles, + messages = messages, + ) + + `when`(metadataStorage.getMetadata()).thenReturn(metadataList) + + val storage = NimbusMessagingStorage( + testContext, + metadataStorage, + reportMalformedMessage, + nimbus, + messagingFeature, + ) + + val results = storage.getMessages() + + assertEquals(2, results.size) + + val message = storage.getNextMessage(HOMESCREEN, results)!! + assertEquals("normal-message", message.id) + } + + @Test + fun `GIVEN dismissed message WHEN calling getMessages THEN filter out the dismissed message`() = + runTest { + val metadataList = mapOf( + "dismissed-message" to Message.Metadata(id = "dismissed-message", dismissed = true), + "normal-message" to Message.Metadata(id = "normal-message", dismissed = false), + ) + val messages = mapOf( + "dismissed-message" to createMessageData(style = "high-priority"), + "normal-message" to createMessageData(style = "high-priority"), + ) + val styles = mapOf( + "high-priority" to createStyle(priority = 100), + ) + val metadataStorage: MessageMetadataStorage = mock() + val (messagingFeature, _) = createMessagingFeature( + styles = styles, + messages = messages, + ) + + `when`(metadataStorage.getMetadata()).thenReturn(metadataList) + + val storage = NimbusMessagingStorage( + testContext, + metadataStorage, + reportMalformedMessage, + nimbus, + messagingFeature, + ) + + val results = storage.getMessages() + assertEquals(2, results.size) + + val message = storage.getNextMessage(HOMESCREEN, results)!! + assertEquals("normal-message", message.id) + } + + @Test + fun `GIVEN a message that the maxDisplayCount WHEN calling getMessages THEN filter out the message`() = + runTest { + val metadataList = mapOf( + "shown-many-times-message" to Message.Metadata( + id = "shown-many-times-message", + displayCount = 10, + ), + "shown-two-times-message" to Message.Metadata( + id = "shown-two-times-message", + displayCount = 2, + ), + "normal-message" to Message.Metadata(id = "normal-message", displayCount = 0), + ) + val messages = mapOf( + "shown-many-times-message" to createMessageData( + style = "high-priority", + ), + "shown-two-times-message" to createMessageData( + style = "high-priority", + ), + "normal-message" to createMessageData(style = "high-priority"), + ) + val styles = mapOf( + "high-priority" to createStyle(priority = 100, maxDisplayCount = 2), + ) + val metadataStorage: MessageMetadataStorage = mock() + val (messagingFeature, _) = createMessagingFeature( + styles = styles, + messages = messages, + ) + + `when`(metadataStorage.getMetadata()).thenReturn(metadataList) + + val storage = NimbusMessagingStorage( + testContext, + metadataStorage, + reportMalformedMessage, + nimbus, + messagingFeature, + ) + + val results = storage.getMessages() + assertEquals(3, results.size) + + val message = storage.getNextMessage(HOMESCREEN, results)!! + assertEquals("normal-message", message.id) + } + + @Test + fun `GIVEN a malformed message WHEN calling getMessages THEN provide a list of messages ignoring the malformed one`() = + runTest { + val messages = storage.getMessages() + val firstMessage = messages.first() + + assertEquals("message-1", firstMessage.id) + assertEquals("message-1", firstMessage.metadata.id) + assertTrue(messages.size == 2) + assertTrue(malformedWasReported) + } + + @Test + fun `GIVEN a malformed action WHEN calling sanitizeAction THEN return null`() { + val actionsMap = mapOf("action-1" to "action-1-url") + + val notFoundAction = + storage.sanitizeAction("no-found-action", actionsMap) + val emptyAction = storage.sanitizeAction("", actionsMap) + val blankAction = storage.sanitizeAction(" ", actionsMap) + + assertNull(notFoundAction) + assertNull(emptyAction) + assertNull(blankAction) + } + + @Test + fun `GIVEN a previously stored malformed action WHEN calling sanitizeAction THEN return null and not report malFormed`() { + val actionsMap = mapOf("action-1" to "action-1-url") + + storage.malFormedMap["malformed-action"] = "messageId" + + val action = storage.sanitizeAction("malformed-action", actionsMap) + + assertNull(action) + assertFalse(malformedWasReported) + } + + @Test + fun `GIVEN a non-previously stored malformed action WHEN calling sanitizeAction THEN return null`() { + val actionsMap = mapOf("action-1" to "action-1-url") + + val action = storage.sanitizeAction("malformed-action", actionsMap) + + assertNull(action) + } + + @Test + fun `WHEN calling updateMetadata THEN delegate to metadataStorage`() = runTest { + storage.updateMetadata(mock()) + + verify(metadataStorage).updateMetadata(any()) + } + + @Test + fun `WHEN calling onMessageDisplayed with message & boot id THEN metadata for count, lastTimeShown & latestBootIdentifier is updated`() = + runTest { + val message = storage.getMessage("message-1")!! + assertEquals(0, message.displayCount) + + val bootId = "test boot id" + val expectedMessage = message.copy( + metadata = Message.Metadata( + id = "message-1", + displayCount = 1, + lastTimeShown = MOCK_TIME_MILLIS, + latestBootIdentifier = bootId, + ), + ) + + assertEquals(expectedMessage, storage.onMessageDisplayed(message, bootId)) + } + + @Test + fun `WHEN calling onMessageDisplayed with message THEN metadata for count, lastTimeShown is updated`() = + runTest { + val message = storage.getMessage("message-1")!! + assertEquals(0, message.displayCount) + + val bootId = null + val expectedMessage = message.copy( + metadata = Message.Metadata( + id = "message-1", + displayCount = 1, + lastTimeShown = MOCK_TIME_MILLIS, + latestBootIdentifier = bootId, + ), + ) + + assertEquals(expectedMessage, storage.onMessageDisplayed(message, bootId)) + } + + @Test + fun `GIVEN a valid action WHEN calling sanitizeAction THEN return the action`() { + val actionsMap = mapOf("action-1" to "action-1-url") + + val validAction = storage.sanitizeAction("action-1", actionsMap) + + assertEquals("action-1-url", validAction) + } + + @Test + fun `GIVEN a trigger action WHEN calling sanitizeTriggers THEN return null`() { + val triggersMap = mapOf("trigger-1" to "trigger-1-expression") + + val notFoundTrigger = + storage.sanitizeTriggers(listOf("no-found-trigger"), triggersMap) + val emptyTrigger = storage.sanitizeTriggers(listOf(""), triggersMap) + val blankTrigger = storage.sanitizeTriggers(listOf(" "), triggersMap) + + assertNull(notFoundTrigger) + assertNull(emptyTrigger) + assertNull(blankTrigger) + } + + @Test + fun `GIVEN a previously stored malformed trigger WHEN calling sanitizeTriggers THEN no report malformed and return null`() { + val triggersMap = mapOf("trigger-1" to "trigger-1-expression") + + storage.malFormedMap[" "] = "messageId" + + val trigger = storage.sanitizeTriggers(listOf(" "), triggersMap) + + assertNull(trigger) + assertFalse(malformedWasReported) + } + + @Test + fun `GIVEN a non previously stored malformed trigger WHEN calling sanitizeTriggers THEN return null`() { + val triggersMap = mapOf("trigger-1" to "trigger-1-expression") + + val trigger = storage.sanitizeTriggers(listOf(" "), triggersMap) + + assertNull(trigger) + } + + @Test + fun `GIVEN a valid trigger WHEN calling sanitizeAction THEN return the trigger`() { + val triggersMap = mapOf("trigger-1" to "trigger-1-expression") + + val validTrigger = storage.sanitizeTriggers(listOf("trigger-1"), triggersMap) + + assertEquals(listOf("trigger-1-expression"), validTrigger) + } + + @Test + fun `GIVEN an eligible message WHEN calling isMessageEligible THEN return true`() { + val helper: NimbusMessagingHelperInterface = mock() + val message = Message( + "same-id", + mock(), + action = "action", + mock(), + listOf("trigger"), + metadata = Message.Metadata("same-id"), + ) + + `when`(helper.evalJexl(any())).thenReturn(true) + + val result = storage.isMessageEligible(message, helper) + + assertTrue(result) + } + + @Test + fun `GIVEN a malformed message key WHEN calling reportMalformedMessage THEN record a malformed feature event`() { + val key = "malformed-message" + storage.reportMalformedMessage(key) + + assertTrue(malformedWasReported) + verify(featuresInterface).recordMalformedConfiguration("messaging", key) + } + + @Test + fun `GIVEN a malformed trigger WHEN calling isMessageEligible THEN return false`() { + val helper: NimbusMessagingHelperInterface = mock() + val message = Message( + "same-id", + mock(), + action = "action", + mock(), + listOf("trigger"), + metadata = Message.Metadata("same-id"), + ) + + `when`(helper.evalJexl(any())).then { throw NimbusException.EvaluationException("") } + + assertThrows(NimbusException.EvaluationException::class.java) { + storage.isMessageEligible(message, helper) + } + } + + @Test + fun `GIVEN a previously malformed trigger WHEN calling isMessageEligible THEN throw and not evaluate`() { + val helper: NimbusMessagingHelperInterface = mock() + val message = Message( + "same-id", + mock(), + action = "action", + mock(), + listOf("trigger"), + metadata = Message.Metadata("same-id"), + ) + + storage.malFormedMap["trigger"] = "same-id" + + `when`(helper.evalJexl(any())).then { throw NimbusException.EvaluationException("") } + + assertThrows(NimbusException.EvaluationException::class.java) { + storage.isMessageEligible(message, helper) + } + + verify(helper, never()).evalJexl("trigger") + } + + @Test + fun `GIVEN a non previously malformed trigger WHEN calling isMessageEligible THEN throw and not evaluate`() { + val helper: NimbusMessagingHelperInterface = mock() + val message = Message( + "same-id", + mock(), + action = "action", + mock(), + listOf("trigger"), + metadata = Message.Metadata("same-id"), + ) + + `when`(helper.evalJexl(any())).then { throw NimbusException.EvaluationException("") } + + assertFalse(storage.malFormedMap.containsKey("trigger")) + + assertThrows(NimbusException.EvaluationException::class.java) { + storage.isMessageEligible(message, helper) + } + + assertTrue(storage.malFormedMap.containsKey("trigger")) + } + + @Test + fun `GIVEN none available messages are eligible WHEN calling getNextMessage THEN return null`() { + val spiedStorage = spy(storage) + val message = Message( + "same-id", + mock(), + action = "action", + mock(), + listOf("trigger"), + metadata = Message.Metadata("same-id"), + ) + + doReturn(false).`when`(spiedStorage).isMessageEligible(any(), any()) + + val result = spiedStorage.getNextMessage(HOMESCREEN, listOf(message)) + + assertNull(result) + } + + @Test + fun `GIVEN an eligible message WHEN calling getNextMessage THEN return the message`() { + val spiedStorage = spy(storage) + val message = Message( + "same-id", + createMessageData(surface = HOMESCREEN), + action = "action", + style = displayOnceStyle, + listOf("trigger"), + metadata = Message.Metadata("same-id"), + ) + + doReturn(true).`when`(spiedStorage).isMessageEligible(any(), any()) + + val result = spiedStorage.getNextMessage(HOMESCREEN, listOf(message)) + + assertEquals(message.id, result!!.id) + } + + @Test + fun `GIVEN a message under experiment WHEN calling onMessageDisplayed THEN call recordExposureEvent`() = runTest { + val spiedStorage = spy(storage) + val experiment = "my-experiment" + val messageData: MessageData = createMessageData(isControl = false, experiment = experiment) + + val message = Message( + "same-id", + messageData, + action = "action", + style = displayOnceStyle, + listOf("trigger"), + metadata = Message.Metadata("same-id"), + ) + + doReturn(true).`when`(spiedStorage).isMessageEligible(any(), any()) + + val result = spiedStorage.getNextMessage(HOMESCREEN, listOf(message)) + verify(featuresInterface, never()).recordExposureEvent("messaging", experiment) + + spiedStorage.onMessageDisplayed(message) + verify(featuresInterface).recordExposureEvent("messaging", experiment) + assertEquals(message.id, result!!.id) + } + + @Test + fun `GIVEN a control message WHEN calling getNextMessage THEN return the next eligible message`() { + val spiedStorage = spy(storage) + val experiment = "my-experiment" + val messageData: MessageData = createMessageData() + val controlMessageData: MessageData = createMessageData(isControl = true, experiment = experiment) + + doReturn(SHOW_NEXT_MESSAGE).`when`(spiedStorage).getOnControlBehavior() + + val message = Message( + "id", + messageData, + action = "action", + style = displayOnceStyle, + listOf("trigger"), + metadata = Message.Metadata("same-id"), + ) + + val controlMessage = Message( + "control-id", + controlMessageData, + action = "action", + style = displayOnceStyle, + listOf("trigger"), + metadata = Message.Metadata("same-id"), + ) + + doReturn(true).`when`(spiedStorage).isMessageEligible(any(), any()) + + val result = spiedStorage.getNextMessage( + HOMESCREEN, + listOf(controlMessage, message), + ) + + verify(messagingFeature).recordExperimentExposure(experiment) + assertEquals(message.id, result!!.id) + } + + @Test + fun `GIVEN a malformed control message WHEN calling getNextMessage THEN return the next eligible message`() { + val spiedStorage = spy(storage) + val messageData: MessageData = createMessageData() + // the message isControl, but has no experiment property. + val controlMessageData: MessageData = createMessageData(isControl = true) + + doReturn(SHOW_NEXT_MESSAGE).`when`(spiedStorage).getOnControlBehavior() + + val message = Message( + "id", + messageData, + action = "action", + style = displayOnceStyle, + listOf("trigger"), + metadata = Message.Metadata("same-id"), + ) + + val controlMessage = Message( + "control-id", + controlMessageData, + action = "action", + style = displayOnceStyle, + listOf("trigger"), + metadata = Message.Metadata("same-id"), + ) + + doReturn(true).`when`(spiedStorage).isMessageEligible(any(), any()) + + val result = spiedStorage.getNextMessage( + HOMESCREEN, + listOf(controlMessage, message), + ) + + verify(messagingFeature).recordMalformedConfiguration("control-id") + + assertEquals(message.id, result!!.id) + } + + @Test + fun `GIVEN a control message WHEN calling getNextMessage THEN return the next eligible message with the correct surface`() { + val spiedStorage = spy(storage) + val experiment = "my-experiment" + val messageData: MessageData = createMessageData() + val incorrectMessageData: MessageData = createMessageData(surface = NOTIFICATION) + val controlMessageData: MessageData = createMessageData(isControl = true, experiment = experiment) + + doReturn(SHOW_NEXT_MESSAGE).`when`(spiedStorage).getOnControlBehavior() + + val message = Message( + "id", + messageData, + action = "action", + style = displayOnceStyle, + listOf("trigger"), + metadata = Message.Metadata("same-id"), + ) + + val incorrectMessage = Message( + "incorrect-id", + incorrectMessageData, + action = "action", + style = displayOnceStyle, + listOf("trigger"), + metadata = Message.Metadata("same-id"), + ) + + val controlMessage = Message( + "control-id", + controlMessageData, + action = "action", + style = displayOnceStyle, + listOf("trigger"), + metadata = Message.Metadata("same-id"), + ) + + doReturn(true).`when`(spiedStorage).isMessageEligible(any(), any()) + + var result = spiedStorage.getNextMessage( + HOMESCREEN, + listOf(controlMessage, incorrectMessage, message), + ) + + verify(messagingFeature, times(1)).recordExperimentExposure(experiment) + assertEquals(message.id, result!!.id) + + result = spiedStorage.getNextMessage( + HOMESCREEN, + listOf(controlMessage, incorrectMessage), + ) + + verify(messagingFeature, times(2)).recordExperimentExposure(experiment) + assertNull(result) + } + + @Test + fun `WHEN a storage instance is created THEN do not invoke the feature`() = runTest { + storage = NimbusMessagingStorage( + testContext, + metadataStorage, + reportMalformedMessage, + nimbus, + messagingFeature, + ) + + // We should not be using the feature holder until getMessages is called. + verify(messagingFeature, never()).value() + } + + @Test + fun `WHEN calling getMessage THEN return message with matching key OR null if doesn't exist`() = + runTest { + val messages = mapOf( + "low-message" to createMessageData(style = "low-priority"), + "high-message" to createMessageData(style = "high-priority"), + "medium-message" to createMessageData(style = "medium-priority"), + ) + val styles = mapOf( + "high-priority" to createStyle(priority = 100), + "medium-priority" to createStyle(priority = 50), + "low-priority" to createStyle(priority = 1), + ) + + val (messagingFeature, _) = createMessagingFeature( + styles = styles, + messages = messages, + ) + + `when`(metadataStorage.getMetadata()).thenReturn( + mapOf( + "message-1" to Message.Metadata(id = "message-1"), + ), + ) + + val storage = NimbusMessagingStorage( + testContext, + metadataStorage, + reportMalformedMessage, + nimbus, + messagingFeature, + ) + + assertEquals("high-message", storage.getMessage("high-message")?.id) + assertEquals("medium-message", storage.getMessage("medium-message")?.id) + assertEquals("low-message", storage.getMessage("low-message")?.id) + assertEquals(null, storage.getMessage("no-message")?.id) + } + + @Test + fun `GIVEN a message without text THEN reject the message and report it as malformed`() = runTest { + val (feature, _) = createMessagingFeature( + styles = mapOf( + "style-1" to createStyle(priority = 100), + ), + triggers = mapOf("trigger-1" to "://trigger-1"), + messages = mapOf( + "missing-text" to createMessageData(text = ""), + "control" to createMessageData(text = "", isControl = true), + "ok" to createMessageData(), + ), + ) + val storage = NimbusMessagingStorage( + testContext, + metadataStorage, + reportMalformedMessage, + nimbus, + feature, + ) + + assertNotNull(storage.getMessage("ok")) + assertNotNull(storage.getMessage("control")) + assertNull(storage.getMessage("missing-text")) + assertTrue(malformedMessageIds.contains("missing-text")) + assertFalse(malformedMessageIds.contains("ok")) + assertFalse(malformedMessageIds.contains("control")) + } + + @Test + fun `GIVEN a message with an action and params THEN do string interpolation`() = runTest { + val (feature, _) = createMessagingFeature( + actions = mapOf("OPEN_URL" to "://open", "INSTALL_FOCUS" to "market://details?app=org.mozilla.focus"), + messages = mapOf( + "open-url" to createMessageData( + action = "OPEN_URL", + actionParams = mapOf("url" to "https://mozilla.org"), + ), + + // with uuid in the param value + "open-url-with-uuid" to createMessageData( + action = "OPEN_URL", + actionParams = mapOf("url" to "https://mozilla.org?uuid={uuid}"), + ), + + // with ? in the action + "install-focus" to createMessageData( + action = "INSTALL_FOCUS", + actionParams = mapOf("utm" to "my-utm"), + ), + ), + ) + val storage = NimbusMessagingStorage( + testContext, + metadataStorage, + reportMalformedMessage, + nimbus, + messagingFeature = feature, + ) + + val myUuid = UUID.randomUUID().toString() + val helper = object : NimbusMessagingHelperInterface { + override fun evalJexl(expression: String) = false + override fun getUuid(template: String): String? = + if (template.contains("{uuid}")) { + myUuid + } else { + null + } + + override fun stringFormat(template: String, uuid: String?): String = + uuid?.let { + template.replace("{uuid}", it) + } ?: template + } + + run { + val message = storage.getMessage("open-url")!! + assertEquals(message.action, "://open") + val (uuid, url) = storage.generateUuidAndFormatMessage(message, helper) + assertEquals(uuid, null) + val urlParam = "https://mozilla.org" + assertEquals(url, "://open?url=${Uri.encode(urlParam)}") + } + + run { + val message = storage.getMessage("open-url-with-uuid")!! + assertEquals(message.action, "://open") + val (uuid, url) = storage.generateUuidAndFormatMessage(message, helper) + assertEquals(uuid, myUuid) + val urlParam = "https://mozilla.org?uuid=$myUuid" + assertEquals(url, "://open?url=${Uri.encode(urlParam)}") + } + + run { + val message = storage.getMessage("install-focus")!! + assertEquals(message.action, "market://details?app=org.mozilla.focus") + val (uuid, url) = storage.generateUuidAndFormatMessage(message, helper) + assertEquals(uuid, null) + assertEquals(url, "market://details?app=org.mozilla.focus&utm=my-utm") + } + } + + private fun createMessageData( + text: String = "text-1", + action: String = "action-1", + actionParams: Map<String, String> = mapOf(), + style: String = "style-1", + triggers: List<String> = listOf("trigger-1"), + surface: MessageSurfaceId = HOMESCREEN, + isControl: Boolean = false, + experiment: String? = null, + ) = MessageData( + action = action, + actionParams = actionParams, + style = style, + triggerIfAll = triggers, + surface = surface, + isControl = isControl, + text = Res.string(text), + experiment = experiment, + ) + + private fun createMessagingFeature( + triggers: Map<String, String> = mapOf("trigger-1" to "trigger-1-expression"), + styles: Map<String, StyleData> = mapOf("style-1" to createStyle()), + actions: Map<String, String> = mapOf("action-1" to "action-1-url"), + messages: Map<String, MessageData> = mapOf( + "message-1" to createMessageData(surface = HOMESCREEN), + "message-2" to createMessageData(surface = NOTIFICATION), + "malformed" to createMessageData(action = "malformed-action"), + "blanktext" to createMessageData(text = ""), + ), + ): Pair<FeatureHolder<Messaging>, FeaturesInterface> { + val messaging = Messaging( + actions = actions, + triggers = triggers, + messages = messages, + styles = styles, + ) + val featureInterface: FeaturesInterface = mock() + // "messaging" is a hard coded value generated from Nimbus. + val messagingFeature = FeatureHolder({ featureInterface }, "messaging") { _, _ -> + messaging + } + messagingFeature.withCachedValue(messaging) + + return messagingFeature to featureInterface + } + + private fun createStyle(priority: Int = 1, maxDisplayCount: Int = 5): StyleData { + val style1: StyleData = mock() + `when`(style1.priority).thenReturn(priority) + `when`(style1.maxDisplayCount).thenReturn(maxDisplayCount) + return style1 + } + + companion object { + private const val HOMESCREEN = "homescreen" + private const val NOTIFICATION = "notification" + } +} diff --git a/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/messaging/OnDiskMessageMetadataStorageTest.kt b/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/messaging/OnDiskMessageMetadataStorageTest.kt new file mode 100644 index 0000000000..e40ee1dffc --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/messaging/OnDiskMessageMetadataStorageTest.kt @@ -0,0 +1,139 @@ +/* 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.nimbus.messaging + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.test.runTest +import mozilla.components.support.test.robolectric.testContext +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +@RunWith(AndroidJUnit4::class) +@kotlinx.coroutines.ExperimentalCoroutinesApi +class OnDiskMessageMetadataStorageTest { + + private lateinit var storage: OnDiskMessageMetadataStorage + + @Before + fun setup() { + storage = OnDiskMessageMetadataStorage( + testContext, + ) + } + + @Test + fun `GIVEN metadata is not loaded from disk WHEN calling getMetadata THEN load it`() = + runTest { + val spiedStorage = spy(storage) + + `when`(spiedStorage.readFromDisk()).thenReturn(emptyMap()) + + spiedStorage.getMetadata() + + verify(spiedStorage).readFromDisk() + } + + @Test + fun `GIVEN metadata is loaded from disk WHEN calling getMetadata THEN do not load it from disk`() = + runTest { + val spiedStorage = spy(storage) + + spiedStorage.metadataMap = hashMapOf("" to Message.Metadata("id")) + + spiedStorage.getMetadata() + + verify(spiedStorage, never()).readFromDisk() + } + + @Test + fun `WHEN calling addMetadata THEN add in memory and disk`() = runTest { + val spiedStorage = spy(storage) + + assertTrue(spiedStorage.metadataMap.isEmpty()) + + `when`(spiedStorage.writeToDisk()).then { } + + spiedStorage.addMetadata(Message.Metadata("id")) + + assertFalse(spiedStorage.metadataMap.isEmpty()) + verify(spiedStorage).writeToDisk() + } + + @Test + fun `WHEN calling updateMetadata THEN delegate to addMetadata`() = runTest { + val spiedStorage = spy(storage) + val metadata = Message.Metadata("id") + `when`(spiedStorage.writeToDisk()).then { } + + spiedStorage.updateMetadata(metadata) + + verify(spiedStorage).addMetadata(metadata) + } + + @Test + fun `WHEN calling toJson THEN return an string json representation`() { + val metadata = Message.Metadata( + id = "id", + displayCount = 1, + pressed = false, + dismissed = false, + lastTimeShown = 0L, + latestBootIdentifier = "9", + ) + + val expected = + """{"id":"id","displayCount":1,"pressed":false,"dismissed":false,"lastTimeShown":0,"latestBootIdentifier":"9"}""" + + assertEquals(expected, metadata.toJson()) + } + + @Test + fun `WHEN calling toMetadata THEN return Metadata representation`() { + val json = + """{"id":"id","displayCount":1,"pressed":false,"dismissed":false,"lastTimeShown":0,"latestBootIdentifier":"9"}""" + + val jsonObject = JSONObject(json) + + val metadata = Message.Metadata( + id = "id", + displayCount = 1, + pressed = false, + dismissed = false, + lastTimeShown = 0L, + latestBootIdentifier = "9", + ) + + assertEquals(metadata, jsonObject.toMetadata()) + } + + @Test + fun `WHEN calling toMetadataMap THEN return map representation`() { + val json = + """[{"id":"id","displayCount":1,"pressed":false,"dismissed":false,"lastTimeShown":0,"latestBootIdentifier":"9"}]""" + + val jsonArray = JSONArray(json) + + val metadata = Message.Metadata( + id = "id", + displayCount = 1, + pressed = false, + dismissed = false, + lastTimeShown = 0L, + latestBootIdentifier = "9", + ) + + assertEquals(metadata, jsonArray.toMetadataMap()[metadata.id]) + } +} diff --git a/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/ui/NimbusBranchItemViewHolderTest.kt b/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/ui/NimbusBranchItemViewHolderTest.kt new file mode 100644 index 0000000000..5a41936e71 --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/ui/NimbusBranchItemViewHolderTest.kt @@ -0,0 +1,92 @@ +/* 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.nimbus.ui + +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.isVisible +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verify +import org.mozilla.experiments.nimbus.Branch + +@RunWith(AndroidJUnit4::class) +class NimbusBranchItemViewHolderTest { + + private val branch = Branch( + slug = "control", + ratio = 1, + ) + private lateinit var nimbusBranchesDelegate: NimbusBranchesAdapterDelegate + private lateinit var selectedIconView: ImageView + private lateinit var titleView: TextView + private lateinit var summaryView: TextView + + @Before + fun setup() { + nimbusBranchesDelegate = mock() + selectedIconView = mock() + titleView = mock() + summaryView = mock() + } + + @Test + fun `GIVEN a branch WHEN bind is called THEN title and summary text is set`() { + val view = View(testContext) + val holder = NimbusBranchItemViewHolder( + view, + nimbusBranchesDelegate, + selectedIconView, + titleView, + summaryView, + ) + + holder.bind(branch, "") + + verify(selectedIconView).isVisible = false + verify(titleView).text = branch.slug + verify(summaryView).text = branch.slug + } + + @Test + fun `WHEN item is clicked THEN delegate is called`() { + val view = View(testContext) + val holder = + NimbusBranchItemViewHolder( + view, + nimbusBranchesDelegate, + selectedIconView, + titleView, + summaryView, + ) + + holder.bind(branch, "") + holder.itemView.performClick() + + verify(nimbusBranchesDelegate).onBranchItemClicked(branch) + } + + @Test + fun `WHEN the selected branch matches THEN the selected icon is visible`() { + val view = View(testContext) + val holder = + NimbusBranchItemViewHolder( + view, + nimbusBranchesDelegate, + selectedIconView, + titleView, + summaryView, + ) + + holder.bind(branch, branch.slug) + + verify(selectedIconView).isVisible = true + } +} diff --git a/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/ui/NimbusExperimentItemViewHolderTest.kt b/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/ui/NimbusExperimentItemViewHolderTest.kt new file mode 100644 index 0000000000..fbdfc40dfa --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/test/java/mozilla/components/service/nimbus/ui/NimbusExperimentItemViewHolderTest.kt @@ -0,0 +1,61 @@ +/* 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.nimbus.ui + +import android.view.View +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verify +import org.mozilla.experiments.nimbus.AvailableExperiment + +@RunWith(AndroidJUnit4::class) +class NimbusExperimentItemViewHolderTest { + + private val experiment = AvailableExperiment( + slug = "secure-gold", + userFacingDescription = "This is a test experiment for diagnostic purposes.", + userFacingName = "Diagnostic test experiment", + branches = emptyList(), + referenceBranch = null, + ) + + private lateinit var nimbusExperimentsDelegate: NimbusExperimentsAdapterDelegate + private lateinit var titleView: TextView + private lateinit var summaryView: TextView + + @Before + fun setup() { + nimbusExperimentsDelegate = mock() + titleView = mock() + summaryView = mock() + } + + @Test + fun `GIVEN a experiment WHEN bind is called THEN title and summary text is set`() { + val view = View(testContext) + val holder = + NimbusExperimentItemViewHolder(view, nimbusExperimentsDelegate, titleView, summaryView) + + holder.bind(experiment) + verify(titleView).text = experiment.userFacingName + verify(summaryView).text = experiment.userFacingDescription + } + + @Test + fun `WHEN item is clicked THEN delegate is called`() { + val view = View(testContext) + val holder = + NimbusExperimentItemViewHolder(view, nimbusExperimentsDelegate, titleView, summaryView) + + holder.bind(experiment) + holder.itemView.performClick() + verify(nimbusExperimentsDelegate).onExperimentItemClicked(experiment) + } +} diff --git a/mobile/android/android-components/components/service/nimbus/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/service/nimbus/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/nimbus/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/mobile/android/android-components/components/service/nimbus/src/test/resources/robolectric.properties b/mobile/android/android-components/components/service/nimbus/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/service/nimbus/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/mobile/android/android-components/components/service/pocket/.gitignore b/mobile/android/android-components/components/service/pocket/.gitignore new file mode 100644 index 0000000000..3c06d0139f --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/.gitignore @@ -0,0 +1,2 @@ +src/test/resources/pocket/apiKey.txt +src/test/resources/pocket/listenAccessToken.txt diff --git a/mobile/android/android-components/components/service/pocket/README.md b/mobile/android/android-components/components/service/pocket/README.md new file mode 100644 index 0000000000..86c2e0ec75 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/README.md @@ -0,0 +1,44 @@ +# [Android Components](../../../README.md) > Service > Pocket + +A library for easily getting Pocket recommendations that transparently handles downloading, caching and periodically refreshing Pocket data. + +Currently this supports: + +- Pocket recommended stories. +- Pocket sponsored stories. + +## Usage +1. For Pocket recommended stories: + - Use `PocketStoriesService#startPeriodicStoriesRefresh` and `PocketStoriesService#stopPeriodicStoriesRefresh` + as high up in the client app as possible (preferably in the Application object or in a single Activity) to ensure the + background story refresh functionality works for the entirety of the app lifetime. + - Use `PocketStoriesService.getStories` to get the current list of Pocket recommended stories. + +2. For Pocket sponsored stories: + - Use `PocketStoriesService#startPeriodicSponsoredStoriesRefresh` and `PocketStoriesService#stopPeriodicSponsoredStoriesRefresh` + as high up in the client app as possible (preferably in the Application object or in a single Activity) to ensure the + background story refresh functionality works for the entirety of the app lifetime. + - Use `PocketStoriesService.getSponsoredStories` to get the current list of Pocket recommended stories. + - Use `PocketStoriesService,recordStoriesImpressions` to try and persist that a list of sponsored stories were shown to the user. (Safe to call even if those stories are not persisted). + - Use `PocketStoriesService.deleteProfile` to delete all server stored information about the device to which sponsored stories were previously downloaded. This may include data like network ip and application tokens. + + ##### Pacing and rotating: + A new `PocketSponsoredStoryCaps` is available in the response from `PocketStoriesService.getSponsoredStories` which allows checking `currentImpressions`, `lifetimeCount`, `flightCount`, `flightPeriod` based on which the client can decide which stories to show. + All this is based on clients calling `PocketStoriesService,recordStoriesImpressions` to record new impressions in between application restarts. + + + + +### 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-pocket:{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/pocket/build.gradle b/mobile/android/android-components/components/service/pocket/build.gradle new file mode 100644 index 0000000000..2835723a98 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/build.gradle @@ -0,0 +1,77 @@ +/* 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' +apply plugin: 'com.google.devtools.ksp' + +android { + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + ksp { + arg("room.schemaLocation", "$projectDir/schemas".toString()) + arg("room.generateKotlin", "true") + } + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + sourceSets { + test.assets.srcDirs += files("$projectDir/schemas".toString()) + androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) + } + + buildFeatures { + buildConfig true + } + + namespace 'mozilla.components.service.pocket' +} + +dependencies { + implementation ComponentsDependencies.kotlin_coroutines + + implementation ComponentsDependencies.androidx_core_ktx + implementation ComponentsDependencies.androidx_work_runtime + implementation ComponentsDependencies.androidx_room_runtime + ksp ComponentsDependencies.androidx_room_compiler + + implementation project(':support-ktx') + implementation project(':support-base') + implementation project(':concept-fetch') + + testImplementation ComponentsDependencies.kotlin_reflect + + testImplementation ComponentsDependencies.androidx_arch_core_testing + testImplementation ComponentsDependencies.androidx_test_core + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.testing_coroutines + testImplementation ComponentsDependencies.testing_robolectric + testImplementation ComponentsDependencies.androidx_room_testing + testImplementation ComponentsDependencies.androidx_work_testing + + testImplementation project(':support-test') + testImplementation project(':lib-fetch-httpurlconnection') + + androidTestImplementation project(':support-android-test') + + androidTestImplementation ComponentsDependencies.androidx_room_testing + androidTestImplementation ComponentsDependencies.androidx_arch_core_testing + androidTestImplementation ComponentsDependencies.androidx_test_core + androidTestImplementation ComponentsDependencies.androidx_test_runner + androidTestImplementation ComponentsDependencies.androidx_test_rules +} + +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/pocket/proguard-rules.pro b/mobile/android/android-components/components/service/pocket/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/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/pocket/schemas/mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase/1.json b/mobile/android/android-components/components/service/pocket/schemas/mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase/1.json new file mode 100644 index 0000000000..04c1aa5bab --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/schemas/mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase/1.json @@ -0,0 +1,70 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "6f93143cfe11253bf96ec0ff80483bcf", + "entities": [ + { + "tableName": "stories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `publisher` TEXT NOT NULL, `category` TEXT NOT NULL, `timeToRead` INTEGER NOT NULL, `timesShown` INTEGER NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timeToRead", + "columnName": "timeToRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timesShown", + "columnName": "timesShown", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6f93143cfe11253bf96ec0ff80483bcf')" + ] + } +} diff --git a/mobile/android/android-components/components/service/pocket/schemas/mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase/2.json b/mobile/android/android-components/components/service/pocket/schemas/mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase/2.json new file mode 100644 index 0000000000..917776ad30 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/schemas/mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase/2.json @@ -0,0 +1,120 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "1ea41b5cc0791d92dd8f0db8b387fe6c", + "entities": [ + { + "tableName": "stories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `publisher` TEXT NOT NULL, `category` TEXT NOT NULL, `timeToRead` INTEGER NOT NULL, `timesShown` INTEGER NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timeToRead", + "columnName": "timeToRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timesShown", + "columnName": "timesShown", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "spocs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `sponsor` TEXT NOT NULL, `clickShim` TEXT NOT NULL, `impressionShim` TEXT NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sponsor", + "columnName": "sponsor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clickShim", + "columnName": "clickShim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "impressionShim", + "columnName": "impressionShim", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1ea41b5cc0791d92dd8f0db8b387fe6c')" + ] + } +} diff --git a/mobile/android/android-components/components/service/pocket/schemas/mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase/3.json b/mobile/android/android-components/components/service/pocket/schemas/mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase/3.json new file mode 100644 index 0000000000..0644b05dca --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/schemas/mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase/3.json @@ -0,0 +1,194 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "966f55824415a21a73640bd2641772f2", + "entities": [ + { + "tableName": "stories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `publisher` TEXT NOT NULL, `category` TEXT NOT NULL, `timeToRead` INTEGER NOT NULL, `timesShown` INTEGER NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timeToRead", + "columnName": "timeToRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timesShown", + "columnName": "timesShown", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "spocs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `sponsor` TEXT NOT NULL, `clickShim` TEXT NOT NULL, `impressionShim` TEXT NOT NULL, `priority` INTEGER NOT NULL, `lifetimeCapCount` INTEGER NOT NULL, `flightCapCount` INTEGER NOT NULL, `flightCapPeriod` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sponsor", + "columnName": "sponsor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clickShim", + "columnName": "clickShim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "impressionShim", + "columnName": "impressionShim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lifetimeCapCount", + "columnName": "lifetimeCapCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flightCapCount", + "columnName": "flightCapCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flightCapPeriod", + "columnName": "flightCapPeriod", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "spocs_impressions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`spocId` INTEGER NOT NULL, `impressionId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `impressionDateInSeconds` INTEGER NOT NULL, FOREIGN KEY(`spocId`) REFERENCES `spocs`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "spocId", + "columnName": "spocId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "impressionId", + "columnName": "impressionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "impressionDateInSeconds", + "columnName": "impressionDateInSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "impressionId" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "spocs", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spocId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '966f55824415a21a73640bd2641772f2')" + ] + } +} diff --git a/mobile/android/android-components/components/service/pocket/schemas/mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase/4.json b/mobile/android/android-components/components/service/pocket/schemas/mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase/4.json new file mode 100644 index 0000000000..b1842722a4 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/schemas/mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase/4.json @@ -0,0 +1,204 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "cc5b4d41781399f6ab7f123c10546acc", + "entities": [ + { + "tableName": "stories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `publisher` TEXT NOT NULL, `category` TEXT NOT NULL, `timeToRead` INTEGER NOT NULL, `timesShown` INTEGER NOT NULL, PRIMARY KEY(`url`))", + "fields": [ + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publisher", + "columnName": "publisher", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timeToRead", + "columnName": "timeToRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timesShown", + "columnName": "timesShown", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "url" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "spocs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `sponsor` TEXT NOT NULL, `clickShim` TEXT NOT NULL, `impressionShim` TEXT NOT NULL, `priority` INTEGER NOT NULL, `lifetimeCapCount` INTEGER NOT NULL, `flightCapCount` INTEGER NOT NULL, `flightCapPeriod` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sponsor", + "columnName": "sponsor", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clickShim", + "columnName": "clickShim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "impressionShim", + "columnName": "impressionShim", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lifetimeCapCount", + "columnName": "lifetimeCapCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flightCapCount", + "columnName": "flightCapCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flightCapPeriod", + "columnName": "flightCapPeriod", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "spocs_impressions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`spocId` INTEGER NOT NULL, `impressionId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `impressionDateInSeconds` INTEGER NOT NULL, FOREIGN KEY(`spocId`) REFERENCES `spocs`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "spocId", + "columnName": "spocId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "impressionId", + "columnName": "impressionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "impressionDateInSeconds", + "columnName": "impressionDateInSeconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "impressionId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_spocs_impressions_spocId", + "unique": false, + "columnNames": [ + "spocId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_spocs_impressions_spocId` ON `${TABLE_NAME}` (`spocId`)" + } + ], + "foreignKeys": [ + { + "table": "spocs", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spocId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cc5b4d41781399f6ab7f123c10546acc')" + ] + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/androidTest/java/mozilla/components/service/pocket/stories/db/PocketRecommendationsDatabaseTest.kt b/mobile/android/android-components/components/service/pocket/src/androidTest/java/mozilla/components/service/pocket/stories/db/PocketRecommendationsDatabaseTest.kt new file mode 100644 index 0000000000..f31f41a318 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/androidTest/java/mozilla/components/service/pocket/stories/db/PocketRecommendationsDatabaseTest.kt @@ -0,0 +1,723 @@ +/* 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.pocket.stories.db + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.test.core.app.ApplicationProvider +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import mozilla.components.service.pocket.spocs.db.SpocEntity +import mozilla.components.service.pocket.spocs.db.SpocImpressionEntity +import mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase.Companion +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +private const val MIGRATION_TEST_DB = "migration-test" + +class PocketRecommendationsDatabaseTest { + private lateinit var context: Context + private lateinit var executor: ExecutorService + private lateinit var database: PocketRecommendationsDatabase + + @get:Rule + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + PocketRecommendationsDatabase::class.java, + ) + + @get:Rule + var instantTaskExecutorRule = InstantTaskExecutorRule() + + @Before + fun setUp() { + executor = Executors.newSingleThreadExecutor() + + context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder(context, PocketRecommendationsDatabase::class.java).build() + } + + @After + fun tearDown() { + executor.shutdown() + database.clearAllTables() + } + + @Test + fun `test1To2MigrationAddsNewSpocsTable`() = runBlocking { + // Create the database with the version 1 schema + val dbVersion1 = helper.createDatabase(MIGRATION_TEST_DB, 1).apply { + execSQL( + "INSERT INTO " + + "'${PocketRecommendationsDatabase.TABLE_NAME_STORIES}' " + + "(url, title, imageUrl, publisher, category, timeToRead, timesShown) " + + "VALUES (" + + "'${story.url}'," + + "'${story.title}'," + + "'${story.imageUrl}'," + + "'${story.publisher}'," + + "'${story.category}'," + + "'${story.timeToRead}'," + + "'${story.timesShown}'" + + ")", + ) + } + // Validate the persisted data which will be re-checked after migration + dbVersion1.query( + "SELECT * FROM ${PocketRecommendationsDatabase.TABLE_NAME_STORIES}", + ).use { cursor -> + assertEquals(1, cursor.count) + + cursor.moveToFirst() + assertEquals( + story, + PocketStoryEntity( + url = cursor.getString(0), + title = cursor.getString(1), + imageUrl = cursor.getString(2), + publisher = cursor.getString(3), + category = cursor.getString(4), + timeToRead = cursor.getInt(5), + timesShown = cursor.getLong(6), + ), + ) + } + + // Migrate the initial database to the version 2 schema + val dbVersion2 = helper.runMigrationsAndValidate( + MIGRATION_TEST_DB, + 2, + true, + Migrations.migration_1_2, + ).apply { + execSQL( + "INSERT INTO " + + "'${PocketRecommendationsDatabase.TABLE_NAME_SPOCS}' " + + "(url, title, imageUrl, sponsor, clickShim, impressionShim) " + + "VALUES (" + + "'${spoc.url}'," + + "'${spoc.title}'," + + "'${spoc.imageUrl}'," + + "'${spoc.sponsor}'," + + "'${spoc.clickShim}'," + + "'${spoc.impressionShim}'" + + ")", + ) + } + // Re-check the initial data we had + dbVersion2.query( + "SELECT * FROM ${PocketRecommendationsDatabase.TABLE_NAME_STORIES}", + ).use { cursor -> + assertEquals(1, cursor.count) + + cursor.moveToFirst() + assertEquals( + story, + PocketStoryEntity( + url = cursor.getString(0), + title = cursor.getString(1), + imageUrl = cursor.getString(2), + publisher = cursor.getString(3), + category = cursor.getString(4), + timeToRead = cursor.getInt(5), + timesShown = cursor.getLong(6), + ), + ) + } + // Finally validate that the new spocs are persisted successfully + dbVersion2.query( + "SELECT * FROM ${PocketRecommendationsDatabase.TABLE_NAME_SPOCS}", + ).use { cursor -> + assertEquals(1, cursor.count) + + cursor.moveToFirst() + assertEquals(spoc.url, cursor.getString(0)) + assertEquals(spoc.title, cursor.getString(1)) + assertEquals(spoc.imageUrl, cursor.getString(2)) + assertEquals(spoc.sponsor, cursor.getString(3)) + assertEquals(spoc.clickShim, cursor.getString(4)) + assertEquals(spoc.impressionShim, cursor.getString(5)) + } + } + + @Test + fun `test2To3MigrationDropsOldSpocsTableAndAddsNewSpocsAndSpocsImpressionsTables`() = runBlocking { + // Create the database with the version 2 schema + val dbVersion2 = helper.createDatabase(MIGRATION_TEST_DB, 2).apply { + execSQL( + "INSERT INTO " + + "'${Companion.TABLE_NAME_STORIES}' " + + "(url, title, imageUrl, publisher, category, timeToRead, timesShown) " + + "VALUES (" + + "'${story.url}'," + + "'${story.title}'," + + "'${story.imageUrl}'," + + "'${story.publisher}'," + + "'${story.category}'," + + "'${story.timeToRead}'," + + "'${story.timesShown}'" + + ")", + ) + execSQL( + "INSERT INTO " + + "'${PocketRecommendationsDatabase.TABLE_NAME_SPOCS}' " + + "(url, title, imageUrl, sponsor, clickShim, impressionShim) " + + "VALUES (" + + "'${spoc.url}'," + + "'${spoc.title}'," + + "'${spoc.imageUrl}'," + + "'${spoc.sponsor}'," + + "'${spoc.clickShim}'," + + "'${spoc.impressionShim}'" + + ")", + ) + } + + // Validate the recommended stories data which will be re-checked after migration + dbVersion2.query( + "SELECT * FROM ${Companion.TABLE_NAME_STORIES}", + ).use { cursor -> + assertEquals(1, cursor.count) + + cursor.moveToFirst() + assertEquals( + story, + PocketStoryEntity( + url = cursor.getString(0), + title = cursor.getString(1), + imageUrl = cursor.getString(2), + publisher = cursor.getString(3), + category = cursor.getString(4), + timeToRead = cursor.getInt(5), + timesShown = cursor.getLong(6), + ), + ) + } + + // Migrate to v3 database + val dbVersion3 = helper.runMigrationsAndValidate( + MIGRATION_TEST_DB, + 3, + true, + Migrations.migration_2_3, + ) + + // Check that recommended stories are unchanged. + dbVersion3.query( + "SELECT * FROM ${PocketRecommendationsDatabase.TABLE_NAME_STORIES}", + ).use { cursor -> + assertEquals(1, cursor.count) + + cursor.moveToFirst() + assertEquals( + story, + PocketStoryEntity( + url = cursor.getString(0), + title = cursor.getString(1), + imageUrl = cursor.getString(2), + publisher = cursor.getString(3), + category = cursor.getString(4), + timeToRead = cursor.getInt(5), + timesShown = cursor.getLong(6), + ), + ) + } + + // Finally validate that we have two new empty tables for spocs and spocs impressions. + dbVersion3.query( + "SELECT * FROM ${PocketRecommendationsDatabase.TABLE_NAME_SPOCS}", + ).use { cursor -> + assertEquals(0, cursor.count) + assertEquals(11, cursor.columnCount) + + assertEquals("id", cursor.getColumnName(0)) + assertEquals("url", cursor.getColumnName(1)) + assertEquals("title", cursor.getColumnName(2)) + assertEquals("imageUrl", cursor.getColumnName(3)) + assertEquals("sponsor", cursor.getColumnName(4)) + assertEquals("clickShim", cursor.getColumnName(5)) + assertEquals("impressionShim", cursor.getColumnName(6)) + assertEquals("priority", cursor.getColumnName(7)) + assertEquals("lifetimeCapCount", cursor.getColumnName(8)) + assertEquals("flightCapCount", cursor.getColumnName(9)) + assertEquals("flightCapPeriod", cursor.getColumnName(10)) + } + dbVersion3.query( + "SELECT * FROM ${PocketRecommendationsDatabase.TABLE_NAME_SPOCS_IMPRESSIONS}", + ).use { cursor -> + assertEquals(0, cursor.count) + assertEquals(3, cursor.columnCount) + + assertEquals("spocId", cursor.getColumnName(0)) + assertEquals("impressionId", cursor.getColumnName(1)) + assertEquals("impressionDateInSeconds", cursor.getColumnName(2)) + } + } + + @Test + fun `test1To3MigrationAddsNewSpocsAndSpocsImpressionsTables`() = runBlocking { + // Create the database with the version 1 schema + val dbVersion1 = helper.createDatabase(MIGRATION_TEST_DB, 1).apply { + execSQL( + "INSERT INTO " + + "'${Companion.TABLE_NAME_STORIES}' " + + "(url, title, imageUrl, publisher, category, timeToRead, timesShown) " + + "VALUES (" + + "'${story.url}'," + + "'${story.title}'," + + "'${story.imageUrl}'," + + "'${story.publisher}'," + + "'${story.category}'," + + "'${story.timeToRead}'," + + "'${story.timesShown}'" + + ")", + ) + } + // Validate the persisted data which will be re-checked after migration + dbVersion1.query( + "SELECT * FROM ${Companion.TABLE_NAME_STORIES}", + ).use { cursor -> + assertEquals(1, cursor.count) + + cursor.moveToFirst() + assertEquals( + story, + PocketStoryEntity( + url = cursor.getString(0), + title = cursor.getString(1), + imageUrl = cursor.getString(2), + publisher = cursor.getString(3), + category = cursor.getString(4), + timeToRead = cursor.getInt(5), + timesShown = cursor.getLong(6), + ), + ) + } + + val impression = SpocImpressionEntity(spoc.id).apply { + impressionId = 1 + impressionDateInSeconds = 700L + } + // Migrate the initial database to the version 2 schema + val dbVersion3 = helper.runMigrationsAndValidate( + MIGRATION_TEST_DB, + 3, + true, + Migrations.migration_1_3, + ).apply { + execSQL( + "INSERT INTO " + + "'${PocketRecommendationsDatabase.TABLE_NAME_SPOCS}' (" + + "id, url, title, imageUrl, sponsor, clickShim, impressionShim, " + + "priority, lifetimeCapCount, flightCapCount, flightCapPeriod" + + ") VALUES (" + + "'${spoc.id}'," + + "'${spoc.url}'," + + "'${spoc.title}'," + + "'${spoc.imageUrl}'," + + "'${spoc.sponsor}'," + + "'${spoc.clickShim}'," + + "'${spoc.impressionShim}'," + + "'${spoc.priority}'," + + "'${spoc.lifetimeCapCount}'," + + "'${spoc.flightCapCount}'," + + "'${spoc.flightCapPeriod}'" + + ")", + ) + + execSQL( + "INSERT INTO " + + "'${PocketRecommendationsDatabase.TABLE_NAME_SPOCS_IMPRESSIONS}' (" + + "spocId, impressionId, impressionDateInSeconds" + + ") VALUES (" + + "'${impression.spocId}'," + + "'${impression.impressionId}'," + + "'${impression.impressionDateInSeconds}'" + + ")", + ) + } + // Re-check the initial data we had + dbVersion3.query( + "SELECT * FROM ${PocketRecommendationsDatabase.TABLE_NAME_STORIES}", + ).use { cursor -> + assertEquals(1, cursor.count) + + cursor.moveToFirst() + assertEquals( + story, + PocketStoryEntity( + url = cursor.getString(0), + title = cursor.getString(1), + imageUrl = cursor.getString(2), + publisher = cursor.getString(3), + category = cursor.getString(4), + timeToRead = cursor.getInt(5), + timesShown = cursor.getLong(6), + ), + ) + } + // Finally validate that the new spocs are persisted successfully + dbVersion3.query( + "SELECT * FROM ${PocketRecommendationsDatabase.TABLE_NAME_SPOCS}", + ).use { cursor -> + assertEquals(1, cursor.count) + + cursor.moveToFirst() + assertEquals(spoc.id, cursor.getInt(0)) + assertEquals(spoc.url, cursor.getString(1)) + assertEquals(spoc.title, cursor.getString(2)) + assertEquals(spoc.imageUrl, cursor.getString(3)) + assertEquals(spoc.sponsor, cursor.getString(4)) + assertEquals(spoc.clickShim, cursor.getString(5)) + assertEquals(spoc.impressionShim, cursor.getString(6)) + assertEquals(spoc.priority, cursor.getInt(7)) + assertEquals(spoc.lifetimeCapCount, cursor.getInt(8)) + assertEquals(spoc.flightCapCount, cursor.getInt(9)) + assertEquals(spoc.flightCapPeriod, cursor.getInt(10)) + } + // And that the impression was also persisted successfully + dbVersion3.query( + "SELECT * FROM ${PocketRecommendationsDatabase.TABLE_NAME_SPOCS_IMPRESSIONS}", + ).use { cursor -> + assertEquals(1, cursor.count) + + cursor.moveToFirst() + assertEquals(impression.spocId, cursor.getInt(0)) + assertEquals(impression.impressionId, cursor.getInt(1)) + assertEquals(impression.impressionDateInSeconds, cursor.getLong(2)) + } + } + + @Test + fun `test3To4MigrationAddsNewIndexKeepsOldDataAndAllowsNewData`() = runBlocking { + // Create the database with the version 3 schema + val dbVersion3 = helper.createDatabase(MIGRATION_TEST_DB, 3).apply { + execSQL( + "INSERT INTO " + + "'${Companion.TABLE_NAME_STORIES}' " + + "(url, title, imageUrl, publisher, category, timeToRead, timesShown) " + + "VALUES (" + + "'${story.url}'," + + "'${story.title}'," + + "'${story.imageUrl}'," + + "'${story.publisher}'," + + "'${story.category}'," + + "'${story.timeToRead}'," + + "'${story.timesShown}'" + + ")", + ) + execSQL( + "INSERT INTO " + + "'${PocketRecommendationsDatabase.TABLE_NAME_SPOCS}' (" + + "id, url, title, imageUrl, sponsor, clickShim, impressionShim, " + + "priority, lifetimeCapCount, flightCapCount, flightCapPeriod" + + ") VALUES (" + + "'${spoc.id}'," + + "'${spoc.url}'," + + "'${spoc.title}'," + + "'${spoc.imageUrl}'," + + "'${spoc.sponsor}'," + + "'${spoc.clickShim}'," + + "'${spoc.impressionShim}'," + + "'${spoc.priority}'," + + "'${spoc.lifetimeCapCount}'," + + "'${spoc.flightCapCount}'," + + "'${spoc.flightCapPeriod}'" + + ")", + ) + execSQL( + "INSERT INTO " + + "'${PocketRecommendationsDatabase.TABLE_NAME_SPOCS_IMPRESSIONS}' (" + + "spocId, impressionId, impressionDateInSeconds" + + ") VALUES (" + + "${spoc.id}, 0, 1" + + ")", + ) + // Add a new impression of the same spoc to test proper the index uniqueness + execSQL( + "INSERT INTO " + + "'${PocketRecommendationsDatabase.TABLE_NAME_SPOCS_IMPRESSIONS}' (" + + "spocId, impressionId, impressionDateInSeconds" + + ") VALUES (" + + "${spoc.id}, 1, 2" + + ")", + ) + } + + // Validate the data before migration + dbVersion3.query( + "SELECT * FROM ${Companion.TABLE_NAME_STORIES}", + ).use { cursor -> + assertEquals(1, cursor.count) + + cursor.moveToFirst() + assertEquals( + story, + PocketStoryEntity( + url = cursor.getString(0), + title = cursor.getString(1), + imageUrl = cursor.getString(2), + publisher = cursor.getString(3), + category = cursor.getString(4), + timeToRead = cursor.getInt(5), + timesShown = cursor.getLong(6), + ), + ) + } + dbVersion3.query( + "SELECT * FROM ${Companion.TABLE_NAME_SPOCS}", + ).use { cursor -> + assertEquals(1, cursor.count) + + cursor.moveToFirst() + assertEquals( + spoc, + SpocEntity( + id = cursor.getInt(0), + url = cursor.getString(1), + title = cursor.getString(2), + imageUrl = cursor.getString(3), + sponsor = cursor.getString(4), + clickShim = cursor.getString(5), + impressionShim = cursor.getString(6), + priority = cursor.getInt(7), + lifetimeCapCount = cursor.getInt(8), + flightCapCount = cursor.getInt(9), + flightCapPeriod = cursor.getInt(10), + ), + ) + } + dbVersion3.query( + "SELECT * FROM ${Companion.TABLE_NAME_SPOCS_IMPRESSIONS}", + ).use { cursor -> + assertEquals(2, cursor.count) + + cursor.moveToFirst() + assertEquals(spoc.id, cursor.getInt(0)) + cursor.moveToNext() + assertEquals(spoc.id, cursor.getInt(0)) + } + + // Migrate to v4 database + val dbVersion4 = helper.runMigrationsAndValidate( + MIGRATION_TEST_DB, + 4, + true, + Migrations.migration_3_4, + ) + + // Check that we have the same data as before. Just that a new index was added for faster queries. + dbVersion4.query( + "SELECT * FROM ${Companion.TABLE_NAME_STORIES}", + ).use { cursor -> + assertEquals(1, cursor.count) + + cursor.moveToFirst() + assertEquals( + story, + PocketStoryEntity( + url = cursor.getString(0), + title = cursor.getString(1), + imageUrl = cursor.getString(2), + publisher = cursor.getString(3), + category = cursor.getString(4), + timeToRead = cursor.getInt(5), + timesShown = cursor.getLong(6), + ), + ) + } + dbVersion4.query( + "SELECT * FROM ${Companion.TABLE_NAME_SPOCS}", + ).use { cursor -> + assertEquals(1, cursor.count) + + cursor.moveToFirst() + assertEquals( + spoc, + SpocEntity( + id = cursor.getInt(0), + url = cursor.getString(1), + title = cursor.getString(2), + imageUrl = cursor.getString(3), + sponsor = cursor.getString(4), + clickShim = cursor.getString(5), + impressionShim = cursor.getString(6), + priority = cursor.getInt(7), + lifetimeCapCount = cursor.getInt(8), + flightCapCount = cursor.getInt(9), + flightCapPeriod = cursor.getInt(10), + ), + ) + } + dbVersion4.query( + "SELECT * FROM ${Companion.TABLE_NAME_SPOCS_IMPRESSIONS}", + ).use { cursor -> + assertEquals(2, cursor.count) + + cursor.moveToFirst() + assertEquals(spoc.id, cursor.getInt(0)) + cursor.moveToNext() + assertEquals(spoc.id, cursor.getInt(0)) + } + + // After adding an index check that inserting new data works as expected + val otherSpoc = spoc.copy( + id = spoc.id + 2, + url = spoc.url + "2", + title = spoc.title + "2", + imageUrl = spoc.imageUrl + "2", + sponsor = spoc.sponsor + "2", + clickShim = spoc.clickShim + "2", + impressionShim = spoc.impressionShim + "2", + priority = spoc.priority + 2, + lifetimeCapCount = spoc.lifetimeCapCount - 2, + flightCapCount = spoc.flightCapPeriod * 2, + flightCapPeriod = spoc.flightCapPeriod / 2, + ) + dbVersion4.execSQL( + "INSERT INTO " + + "'${PocketRecommendationsDatabase.TABLE_NAME_SPOCS}' (" + + "id, url, title, imageUrl, sponsor, clickShim, impressionShim, " + + "priority, lifetimeCapCount, flightCapCount, flightCapPeriod" + + ") VALUES (" + + "'${otherSpoc.id}'," + + "'${otherSpoc.url}'," + + "'${otherSpoc.title}'," + + "'${otherSpoc.imageUrl}'," + + "'${otherSpoc.sponsor}'," + + "'${otherSpoc.clickShim}'," + + "'${otherSpoc.impressionShim}'," + + "'${otherSpoc.priority}'," + + "'${otherSpoc.lifetimeCapCount}'," + + "'${otherSpoc.flightCapCount}'," + + "'${otherSpoc.flightCapPeriod}'" + + ")", + ) + dbVersion4.execSQL( + "INSERT INTO " + + "'${PocketRecommendationsDatabase.TABLE_NAME_SPOCS_IMPRESSIONS}' (" + + "spocId, impressionId, impressionDateInSeconds" + + ") VALUES (" + + "${spoc.id}, 22, 33" + + ")", + ) + // Test a new spoc and a new impressions of it are properly recorded.Z + dbVersion4.execSQL( + "INSERT INTO " + + "'${PocketRecommendationsDatabase.TABLE_NAME_SPOCS_IMPRESSIONS}' (" + + "spocId, impressionId, impressionDateInSeconds" + + ") VALUES (" + + "${otherSpoc.id}, 23, 34" + + ")", + ) + // Add a new impression of the same spoc to test proper the index uniqueness + dbVersion4.execSQL( + "INSERT INTO " + + "'${PocketRecommendationsDatabase.TABLE_NAME_SPOCS_IMPRESSIONS}' (" + + "spocId, impressionId, impressionDateInSeconds" + + ") VALUES (" + + "${otherSpoc.id}, 24, 35" + + ")", + ) + dbVersion4.query( + "SELECT * FROM ${Companion.TABLE_NAME_SPOCS} ORDER BY 'id'", + ).use { cursor -> + assertEquals(2, cursor.count) + + cursor.moveToFirst() + assertEquals( + spoc, + SpocEntity( + id = cursor.getInt(0), + url = cursor.getString(1), + title = cursor.getString(2), + imageUrl = cursor.getString(3), + sponsor = cursor.getString(4), + clickShim = cursor.getString(5), + impressionShim = cursor.getString(6), + priority = cursor.getInt(7), + lifetimeCapCount = cursor.getInt(8), + flightCapCount = cursor.getInt(9), + flightCapPeriod = cursor.getInt(10), + ), + ) + + cursor.moveToNext() + assertEquals( + otherSpoc, + SpocEntity( + id = cursor.getInt(0), + url = cursor.getString(1), + title = cursor.getString(2), + imageUrl = cursor.getString(3), + sponsor = cursor.getString(4), + clickShim = cursor.getString(5), + impressionShim = cursor.getString(6), + priority = cursor.getInt(7), + lifetimeCapCount = cursor.getInt(8), + flightCapCount = cursor.getInt(9), + flightCapPeriod = cursor.getInt(10), + ), + ) + } + dbVersion4.query( + "SELECT * FROM ${Companion.TABLE_NAME_SPOCS_IMPRESSIONS} ORDER BY 'impressionId'", + ).use { cursor -> + assertEquals(5, cursor.count) + + cursor.moveToFirst() + assertEquals(spoc.id, cursor.getInt(0)) + assertEquals(0, cursor.getInt(1)) + assertEquals(1, cursor.getInt(2)) + cursor.moveToNext() + assertEquals(spoc.id, cursor.getInt(0)) + assertEquals(1, cursor.getInt(1)) + assertEquals(2, cursor.getInt(2)) + cursor.moveToNext() + assertEquals(spoc.id, cursor.getInt(0)) + assertEquals(22, cursor.getInt(1)) + assertEquals(33, cursor.getInt(2)) + cursor.moveToNext() + assertEquals(otherSpoc.id, cursor.getInt(0)) + assertEquals(23, cursor.getInt(1)) + assertEquals(34, cursor.getInt(2)) + cursor.moveToNext() + assertEquals(otherSpoc.id, cursor.getInt(0)) + assertEquals(24, cursor.getInt(1)) + assertEquals(35, cursor.getInt(2)) + } + } +} + +private val story = PocketStoryEntity( + title = "How to Get Rid of Black Mold Naturally", + url = "https://getpocket.com/explore/item/how-to-get-rid-of-black-mold-naturally", + imageUrl = "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpocket-image-cache.com%2F1200x%2Ffilters%3Aformat(jpg)%3Aextract_focal()%2Fhttps%253A%252F%252Fpocket-syndicated-images.s3.amazonaws.com%252Farticles%252F6757%252F1628024495_6109ae86db6cc.png", + publisher = "Pocket", + category = "general", + timeToRead = 4, + timesShown = 23, +) + +private val spoc = SpocEntity( + id = 191739319, + url = "https://i.geistm.com/l/GC_7ReasonsKetoV2_Journiest?bcid=601c567ac5b18a0414cce1d4&bhid=624f3ea9adad7604086ac6b3&utm_content=PKT_A_7ReasonsKetoV2_Journiest_40702022_RawMeatballUGC_130Off_601c567ac5b18a0414cce1d4_624f3ea9adad7604086ac6b3&tv=su4&ct=NAT-PK-PROS-130OFF5WEEK-037&utm_medium=DB&utm_source=pocket~geistm&utm_campaign=PKT_A_7ReasonsKetoV2_Journiest_40702022_RawMeatballUGC_130Off", + title = "Eating Keto Has Never Been So Easy With Green Chef", + imageUrl = "https://img-getpocket.cdn.mozilla.net/direct?url=realUrl.png&resize=w618-h310", + sponsor = "Green Chef", + clickShim = "193815086ClickShim", + impressionShim = "193815086ImpressionShim", + priority = 3, + lifetimeCapCount = 50, + flightCapCount = 10, + flightCapPeriod = 86400, +) diff --git a/mobile/android/android-components/components/service/pocket/src/main/AndroidManifest.xml b/mobile/android/android-components/components/service/pocket/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e16cda1d34 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/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/pocket/src/main/java/mozilla/components/service/pocket/GlobalDependencyProvider.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/GlobalDependencyProvider.kt new file mode 100644 index 0000000000..f05dd7dbe7 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/GlobalDependencyProvider.kt @@ -0,0 +1,69 @@ +/* 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.pocket + +import android.annotation.SuppressLint +import mozilla.components.service.pocket.spocs.SpocsUseCases +import mozilla.components.service.pocket.stories.PocketStoriesUseCases + +/** + * Provides global access to the dependencies needed for updating Pocket stories. + */ +internal object GlobalDependencyProvider { + internal object RecommendedStories { + /** + * Possible actions regarding the list of recommended stories. + */ + @SuppressLint("StaticFieldLeak") + internal var useCases: PocketStoriesUseCases? = null + private set + + /** + * Convenience method for setting all details used when communicating with the Pocket server. + * + * @param useCases [PocketStoriesUseCases] containing all possible actions regarding + * the list of recommended stories. + */ + internal fun initialize( + useCases: PocketStoriesUseCases, + ) { + this.useCases = useCases + } + + /** + * Convenience method for cleaning up any resources held for communicating with the Pocket server. + */ + internal fun reset() { + this.useCases = null + } + } + + internal object SponsoredStories { + /** + * Possible actions regarding the list of sponsored stories. + */ + @SuppressLint("StaticFieldLeak") + internal var useCases: SpocsUseCases? = null + private set + + /** + * Convenience method for setting all details used when communicating with the Pocket server. + * + * @param useCases [SpocsUseCases] containing all possible actions regarding the list of sponsored stories. + */ + internal fun initialize( + useCases: SpocsUseCases, + ) { + this.useCases = useCases + } + + /** + * Convenience method for cleaning up any resources held for communicating with the Pocket server. + */ + internal fun reset() { + useCases = null + } + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/Logger.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/Logger.kt new file mode 100644 index 0000000000..ab1732b707 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/Logger.kt @@ -0,0 +1,12 @@ +/* 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.pocket + +import mozilla.components.support.base.log.logger.Logger + +/** + * Internal logger for the ":service-pocket" module. + */ +internal val logger = Logger("service-pocket") diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/PocketStoriesConfig.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/PocketStoriesConfig.kt new file mode 100644 index 0000000000..ba077afb0b --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/PocketStoriesConfig.kt @@ -0,0 +1,68 @@ +/* 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.pocket + +import mozilla.components.concept.fetch.Client +import mozilla.components.support.base.worker.Frequency +import java.util.UUID +import java.util.concurrent.TimeUnit + +internal const val DEFAULT_SPONSORED_STORIES_SITE_ID = "1240699" +internal const val DEFAULT_REFRESH_INTERVAL = 4L +internal const val DEFAULT_SPONSORED_STORIES_REFRESH_INTERVAL = 4L + +@Suppress("TopLevelPropertyNaming") +internal val DEFAULT_REFRESH_TIMEUNIT = TimeUnit.HOURS + +@Suppress("TopLevelPropertyNaming") +internal val DEFAULT_SPONSORED_STORIES_REFRESH_TIMEUNIT = TimeUnit.HOURS + +/** + * Indicating all details for how the pocket stories should be refreshed. + * + * @param client [Client] implementation used for downloading the Pocket stories. + * @param frequency Optional - The interval at which to try and refresh items. Defaults to 4 hours. + * @param profile Optional - The profile used for downloading sponsored Pocket stories. + * @param sponsoredStoriesRefreshFrequency Optional - The interval at which to try and refresh sponsored stories. + * Defaults to 4 hours. + * @param sponsoredStoriesParams Optional - Configuration containing parameters used to get the spoc content. + */ +class PocketStoriesConfig( + val client: Client, + val frequency: Frequency = Frequency( + DEFAULT_REFRESH_INTERVAL, + DEFAULT_REFRESH_TIMEUNIT, + ), + val profile: Profile? = null, + val sponsoredStoriesRefreshFrequency: Frequency = Frequency( + DEFAULT_SPONSORED_STORIES_REFRESH_INTERVAL, + DEFAULT_SPONSORED_STORIES_REFRESH_TIMEUNIT, + ), + val sponsoredStoriesParams: PocketStoriesRequestConfig = PocketStoriesRequestConfig(), +) + +/** + * Configuration for sponsored stories request indicating parameters used to get spoc content. + * + * @property siteId Optional - ID of the site parameter, should be used with care as it changes the + * set of sponsored stories fetched from the server. + * @property country Optional - Value of the country parameter, shall be used with care as it allows + * overriding the IP location and receiving a set of sponsored stories not suited for the real location. + * @property city Optional - Value of the city parameter, shall be used with care as it allows + * overriding the IP location and receiving a set of sponsored stories not suited for the real location. + */ +class PocketStoriesRequestConfig( + val siteId: String = DEFAULT_SPONSORED_STORIES_SITE_ID, + val country: String = "", + val city: String = "", +) + +/** + * Sponsored stories configuration data. + * + * @param profileId Unique profile identifier which will be presented with sponsored stories. + * @param appId Unique identifier of the application using this feature. + */ +class Profile(val profileId: UUID, val appId: String) diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/PocketStoriesService.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/PocketStoriesService.kt new file mode 100644 index 0000000000..6688d75926 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/PocketStoriesService.kt @@ -0,0 +1,172 @@ +/* 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.pocket + +import android.content.Context +import androidx.annotation.VisibleForTesting +import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory +import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory +import mozilla.components.service.pocket.spocs.SpocsUseCases +import mozilla.components.service.pocket.stories.PocketStoriesUseCases +import mozilla.components.service.pocket.update.PocketStoriesRefreshScheduler +import mozilla.components.service.pocket.update.SpocsRefreshScheduler + +/** + * Allows for getting a list of pocket stories based on the provided [PocketStoriesConfig] + * + * @param context Android Context. Prefer sending application context to limit the possibility of even small leaks. + * @param pocketStoriesConfig configuration for how and what pocket stories to get. + */ +class PocketStoriesService( + private val context: Context, + private val pocketStoriesConfig: PocketStoriesConfig, +) { + @VisibleForTesting + internal var storiesRefreshScheduler = PocketStoriesRefreshScheduler(pocketStoriesConfig) + + @VisibleForTesting + internal var spocsRefreshscheduler = SpocsRefreshScheduler(pocketStoriesConfig) + + @VisibleForTesting + internal var storiesUseCases = PocketStoriesUseCases( + appContext = context, + fetchClient = pocketStoriesConfig.client, + ) + + @VisibleForTesting + internal var spocsUseCases = when (pocketStoriesConfig.profile) { + null -> { + logger.debug("Missing profile for sponsored stories") + null + } + else -> SpocsUseCases( + appContext = context, + fetchClient = pocketStoriesConfig.client, + profileId = pocketStoriesConfig.profile.profileId, + appId = pocketStoriesConfig.profile.appId, + sponsoredStoriesParams = pocketStoriesConfig.sponsoredStoriesParams, + ) + } + + /** + * Entry point to start fetching Pocket stories in the background. + * + * Use this at an as high as possible level in your application. + * Must be paired in a similar way with the [stopPeriodicStoriesRefresh] method. + * + * This starts the process of downloading and caching Pocket stories in the background, + * making them available for the [getStories] method. + */ + fun startPeriodicStoriesRefresh() { + GlobalDependencyProvider.RecommendedStories.initialize(storiesUseCases) + storiesRefreshScheduler.schedulePeriodicRefreshes(context) + } + + /** + * Single stopping point for the "get Pocket stories" functionality. + * + * Use this at an as high as possible level in your application. + * Must be paired in a similar way with the [startPeriodicStoriesRefresh] method. + * + * This stops the process of downloading and caching Pocket stories in the background. + */ + fun stopPeriodicStoriesRefresh() { + storiesRefreshScheduler.stopPeriodicRefreshes(context) + GlobalDependencyProvider.RecommendedStories.reset() + } + + /** + * Get a list of Pocket recommended stories based on the initial configuration. + * + * To be called after [startPeriodicStoriesRefresh] to ensure the recommendations are up-to-date. + * Might return an empty list or a list of older than expected stories if + * [startPeriodicStoriesRefresh] hasn't yet completed. + */ + suspend fun getStories(): List<PocketRecommendedStory> { + return storiesUseCases.getStories() + } + + /** + * Entry point to start fetching Pocket sponsored stories in the background. + * + * Use this at an as high as possible level in your application. + * Must be paired in a similar way with the [stopPeriodicSponsoredStoriesRefresh] method. + * + * This starts the process of downloading and caching Pocket sponsored stories in the background, + * making them available for the [getSponsoredStories] method. + */ + fun startPeriodicSponsoredStoriesRefresh() { + val useCases = spocsUseCases + if (useCases == null) { + logger.warn("Cannot start sponsored stories refresh. Service has incomplete setup") + return + } + + GlobalDependencyProvider.SponsoredStories.initialize(useCases) + spocsRefreshscheduler.stopProfileDeletion(context) + spocsRefreshscheduler.schedulePeriodicRefreshes(context) + } + + /** + * Single stopping point for the "refresh sponsored Pocket stories" functionality. + * + * Use this at an as high as possible level in your application. + * Must be paired in a similar way with the [startPeriodicSponsoredStoriesRefresh] method. + * + * This stops the process of downloading and caching Pocket sponsored stories in the background. + */ + fun stopPeriodicSponsoredStoriesRefresh() { + spocsRefreshscheduler.stopPeriodicRefreshes(context) + } + + /** + * Fetch sponsored Pocket stories and refresh the locally persisted list. + */ + suspend fun refreshSponsoredStories() { + spocsUseCases?.refreshStories?.invoke() + } + + /** + * Get a list of Pocket sponsored stories based on the initial configuration. + */ + suspend fun getSponsoredStories(): List<PocketSponsoredStory> { + return spocsUseCases?.getStories?.invoke() ?: emptyList() + } + + /** + * Delete all stored user data used for downloading personalized sponsored stories. + * This returns immediately but will handle the profile deletion in background. + */ + fun deleteProfile() { + val useCases = spocsUseCases + if (useCases == null) { + logger.warn("Cannot delete sponsored stories profile. Service has incomplete setup") + return + } + + GlobalDependencyProvider.SponsoredStories.initialize(useCases) + spocsRefreshscheduler.stopPeriodicRefreshes(context) + spocsRefreshscheduler.scheduleProfileDeletion(context) + } + + /** + * Update how many times certain stories were shown to the user. + * + * Safe to call from any background thread. + * Automatically synchronized with the other [PocketStoriesService] methods. + */ + suspend fun updateStoriesTimesShown(updatedStories: List<PocketRecommendedStory>) { + storiesUseCases.updateTimesShown(updatedStories) + } + + /** + * Persist locally that the sponsored Pocket stories containing the ids from [storiesShown] + * were shown to the user. + * This is safe to call with any ids, even ones for stories not currently persisted anymore. + */ + suspend fun recordStoriesImpressions(storiesShown: List<Int>) { + spocsUseCases?.recordImpression?.invoke(storiesShown) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/PocketStory.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/PocketStory.kt new file mode 100644 index 0000000000..702641268d --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/PocketStory.kt @@ -0,0 +1,99 @@ +/* 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.pocket + +/** + * A Pocket story downloaded from the Internet and intended to be displayed in the application. + */ +sealed class PocketStory { + /** + * Title of the story. + */ + abstract val title: String + + /** + * Url where the story can be full read. + */ + abstract val url: String + + /** + * A Pocket recommended story. + * + * @property title The title of the story. + * @property url A "pocket.co" shortlink for the original story's page. + * @property imageUrl A url to a still image representing the story. + * @property publisher Optional publisher name/domain, e.g. "The New Yorker" / "nationalgeographic.co.uk"". + * **May be empty**. + * @property category Topic of interest under which similar stories are grouped. + * @property timeToRead Inferred time needed to read the entire story. **May be -1**. + */ + data class PocketRecommendedStory( + override val title: String, + override val url: String, + val imageUrl: String, + val publisher: String, + val category: String, + val timeToRead: Int, + val timesShown: Long, + ) : PocketStory() + + /** + * A Pocket sponsored story. + * + * @property id Unique id of this story. + * @property title The title of the story. + * @property url 3rd party url containing the original story. + * @property imageUrl A url to a still image representing the story. + * Contains a "resize" parameter in the form of "resize=w618-h310" allowing to get the image + * with a specific resolution and the CENTER_CROP ScaleType. + * @property sponsor 3rd party sponsor of this story, e.g. "NextAdvisor". + * @property shim Unique identifiers for when the user interacts with this story. + * @property priority Priority level in deciding which stories to be shown first. + * A lowest number means a higher priority. + * @property caps Story caps indented to control the maximum number of times the story should be shown. + */ + data class PocketSponsoredStory( + val id: Int, + override val title: String, + override val url: String, + val imageUrl: String, + val sponsor: String, + val shim: PocketSponsoredStoryShim, + val priority: Int, + val caps: PocketSponsoredStoryCaps, + ) : PocketStory() + + /** + * Sponsored story unique identifiers intended to be used in telemetry. + * + * @property click Unique identifier for when the sponsored story is clicked. + * @property impression Unique identifier for when the user sees this sponsored story. + */ + data class PocketSponsoredStoryShim( + val click: String, + val impression: String, + ) + + /** + * Sponsored story caps indented to control the maximum number of times the story should be shown. + * + * @property currentImpressions List of all recorded impression of a sponsored Pocket story + * expressed in seconds from Epoch (as the result of `System.currentTimeMillis / 1000`). + * @property lifetimeCount Lifetime maximum number of times this story should be shown. + * This is independent from the count based on [flightCount] and [flightPeriod] and must never be reset. + * @property flightCount Maximum number of times this story should be shown in [flightPeriod]. + * @property flightPeriod Period expressed as a number of seconds in which this story should be shown + * for at most [flightCount] times. + * Any time the period comes to an end the [flightCount] count should be restarted. + * Even if based on [flightCount] and [flightCount] this story can still be shown a couple more times + * if [lifetimeCount] was met then the story should not be shown anymore. + */ + data class PocketSponsoredStoryCaps( + val currentImpressions: List<Long> = emptyList(), + val lifetimeCount: Int, + val flightCount: Int, + val flightPeriod: Int, + ) +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/ext/ConceptFetch.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/ext/ConceptFetch.kt new file mode 100644 index 0000000000..24e4f0871f --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/ext/ConceptFetch.kt @@ -0,0 +1,30 @@ +/* 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.pocket.ext + +import androidx.annotation.WorkerThread +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.concept.fetch.isSuccess +import mozilla.components.service.pocket.logger +import java.io.IOException + +// extension functions for :concept-fetch module. + +/** + * @return returns the string contained within the response body for the given [request] or null, on error. + */ +@WorkerThread // synchronous network call. +internal fun Client.fetchBodyOrNull(request: Request): String? { + val response: Response? = try { + fetch(request) + } catch (e: IOException) { + logger.debug("network error", e) + null + } + + return response?.use { if (response.isSuccess) response.body.string() else null } +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/ext/Mappers.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/ext/Mappers.kt new file mode 100644 index 0000000000..42df3c1507 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/ext/Mappers.kt @@ -0,0 +1,99 @@ +/* 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.pocket.ext + +import androidx.annotation.VisibleForTesting +import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory +import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory +import mozilla.components.service.pocket.PocketStory.PocketSponsoredStoryCaps +import mozilla.components.service.pocket.PocketStory.PocketSponsoredStoryShim +import mozilla.components.service.pocket.spocs.api.ApiSpoc +import mozilla.components.service.pocket.spocs.db.SpocEntity +import mozilla.components.service.pocket.stories.api.PocketApiStory +import mozilla.components.service.pocket.stories.db.PocketLocalStoryTimesShown +import mozilla.components.service.pocket.stories.db.PocketStoryEntity + +@VisibleForTesting +internal const val DEFAULT_CATEGORY = "general" + +@VisibleForTesting +internal const val DEFAULT_TIMES_SHOWN = 0L + +/** + * Map Pocket API objects to the object type that we persist locally. + */ +internal fun PocketApiStory.toPocketLocalStory(): PocketStoryEntity = + PocketStoryEntity( + url, + title, + imageUrl, + publisher, + category, + timeToRead, + DEFAULT_TIMES_SHOWN, + ) + +/** + * Map Room entities to the object type that we expose to service clients. + */ +internal fun PocketStoryEntity.toPocketRecommendedStory(): PocketRecommendedStory = + PocketRecommendedStory( + url = url, + title = title, + imageUrl = imageUrl, + publisher = publisher, + category = if (category.isNotBlank()) category else DEFAULT_CATEGORY, + timeToRead = timeToRead, + timesShown = timesShown, + ) + +/** + * Maps an object of the type exposed to clients to one that can partially update only the "timesShown" + * property of the type we persist locally. + */ +internal fun PocketRecommendedStory.toPartialTimeShownUpdate(): PocketLocalStoryTimesShown = + PocketLocalStoryTimesShown(url, timesShown) + +/** + * Map sponsored Pocket stories to the object type that we persist locally. + */ +internal fun ApiSpoc.toLocalSpoc(): SpocEntity = + SpocEntity( + id = id, + url = url, + title = title, + imageUrl = imageSrc, + sponsor = sponsor, + clickShim = shim.click, + impressionShim = shim.impression, + priority = priority, + lifetimeCapCount = caps.lifetimeCount, + flightCapCount = caps.flightCount, + flightCapPeriod = caps.flightPeriod, + ) + +/** + * Map Room entities to the object type that we expose to service clients. + */ +internal fun SpocEntity.toPocketSponsoredStory( + impressions: List<Long> = emptyList(), +) = PocketSponsoredStory( + id = id, + title = title, + url = url, + imageUrl = imageUrl, + sponsor = sponsor, + shim = PocketSponsoredStoryShim( + click = clickShim, + impression = impressionShim, + ), + priority = priority, + caps = PocketSponsoredStoryCaps( + currentImpressions = impressions, + lifetimeCount = lifetimeCapCount, + flightCount = flightCapCount, + flightPeriod = flightCapPeriod, + ), +) diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/ext/PocketStory.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/ext/PocketStory.kt new file mode 100644 index 0000000000..f111e84747 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/ext/PocketStory.kt @@ -0,0 +1,50 @@ +/* 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.pocket.ext + +import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory +import mozilla.components.service.pocket.PocketStory.PocketSponsoredStoryCaps +import java.util.concurrent.TimeUnit + +/** + * Get a list of all story impressions (expressed in seconds from Epoch) in the period between + * `now` down to [PocketSponsoredStoryCaps.flightPeriod]. + */ +fun PocketSponsoredStory.getCurrentFlightImpressions(): List<Long> { + val now = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) + return caps.currentImpressions.filter { + now - it < caps.flightPeriod + } +} + +/** + * Get if this story was already shown for the maximum number of times available in it's lifetime. + */ +fun PocketSponsoredStory.hasLifetimeImpressionsLimitReached(): Boolean { + return caps.currentImpressions.size >= caps.lifetimeCount +} + +/** + * Get if this story was already shown for the maximum number of times available in the period + * specified by [PocketSponsoredStoryCaps.flightPeriod]. + */ +fun PocketSponsoredStory.hasFlightImpressionsLimitReached(): Boolean { + return getCurrentFlightImpressions().size >= caps.flightCount +} + +/** + * Record a new impression at this instant time and get this story back with updated impressions details. + * This only updates the in-memory data. + * + * It's recommended to use this method anytime a new impression needs to be recorded for a `PocketSponsoredStory` + * to ensure values consistency. + */ +fun PocketSponsoredStory.recordNewImpression(): PocketSponsoredStory { + return this.copy( + caps = caps.copy( + currentImpressions = caps.currentImpressions + TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()), + ), + ) +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/SpocsRepository.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/SpocsRepository.kt new file mode 100644 index 0000000000..609b6ae935 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/SpocsRepository.kt @@ -0,0 +1,69 @@ +/* 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.pocket.spocs + +import android.content.Context +import androidx.annotation.VisibleForTesting +import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory +import mozilla.components.service.pocket.ext.toLocalSpoc +import mozilla.components.service.pocket.ext.toPocketSponsoredStory +import mozilla.components.service.pocket.spocs.api.ApiSpoc +import mozilla.components.service.pocket.spocs.db.SpocImpressionEntity +import mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase + +/** + * Wrapper over our local database containing Spocs. + * Allows for easy CRUD operations. + */ +internal class SpocsRepository(context: Context) { + private val database: Lazy<PocketRecommendationsDatabase> = lazy { PocketRecommendationsDatabase.get(context) } + + @VisibleForTesting + internal val spocsDao by lazy { database.value.spocsDao() } + + /** + * Get the current locally persisted list of sponsored Pocket stories + * complete with the list of all locally persisted impressions data. + */ + suspend fun getAllSpocs(): List<PocketSponsoredStory> { + val spocs = spocsDao.getAllSpocs() + val impressions = spocsDao.getSpocsImpressions().groupBy { it.spocId } + + return spocs.map { spoc -> + spoc.toPocketSponsoredStory( + impressions[spoc.id] + ?.map { impression -> impression.impressionDateInSeconds } + ?: emptyList(), + ) + } + } + + /** + * Delete all currently persisted sponsored Pocket stories. + */ + suspend fun deleteAllSpocs() { + spocsDao.deleteAllSpocs() + } + + /** + * Replace the current list of locally persisted sponsored Pocket stories. + * + * @param spocs The list of sponsored Pocket stories to persist locally. + */ + suspend fun addSpocs(spocs: List<ApiSpoc>) { + spocsDao.cleanOldAndInsertNewSpocs(spocs.map { it.toLocalSpoc() }) + } + + /** + * Add a new impression record for each of the spocs identified by the ids from [spocsShown]. + * Will ignore adding new entries if the intended spocs are not persisted locally anymore. + * Recorded entries will automatically be cleaned when the spoc they target is deleted. + * + * @param spocsShown List of [PocketSponsoredStory.id] for which to record new impressions. + */ + suspend fun recordImpressions(spocsShown: List<Int>) { + spocsDao.recordImpressions(spocsShown.map { SpocImpressionEntity(it) }) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/SpocsUseCases.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/SpocsUseCases.kt new file mode 100644 index 0000000000..d0f7e8fa75 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/SpocsUseCases.kt @@ -0,0 +1,186 @@ +/* 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.pocket.spocs + +import android.content.Context +import androidx.annotation.VisibleForTesting +import mozilla.components.concept.fetch.Client +import mozilla.components.service.pocket.PocketStoriesRequestConfig +import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory +import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory +import mozilla.components.service.pocket.spocs.api.SpocsEndpoint +import mozilla.components.service.pocket.stories.api.PocketResponse.Failure +import mozilla.components.service.pocket.stories.api.PocketResponse.Success +import java.util.UUID + +/** + * Possible actions regarding the list of sponsored stories. + * + * @param appContext Android Context. Prefer sending application context to limit the possibility of even small leaks. + * @param fetchClient the HTTP client to use for network requests. + * @param profileId Unique profile identifier used for downloading sponsored Pocket stories. + * @param appId Unique app identifier used for downloading sponsored Pocket stories. + * @param sponsoredStoriesParams Configuration containing parameters used to get the spoc content. + */ +internal class SpocsUseCases( + private val appContext: Context, + private val fetchClient: Client, + private val profileId: UUID, + private val appId: String, + private val sponsoredStoriesParams: PocketStoriesRequestConfig, +) { + /** + * Download and persist an updated list of sponsored stories. + */ + internal val refreshStories by lazy { + RefreshSponsoredStories(appContext, fetchClient, profileId, appId) + } + + /** + * Get the list of available Pocket sponsored stories. + */ + internal val getStories by lazy { + GetSponsoredStories(appContext) + } + + internal val recordImpression by lazy { + RecordImpression(appContext) + } + + /** + * Delete all stored user data used for downloading sponsored stories. + */ + internal val deleteProfile by lazy { + DeleteProfile(appContext, fetchClient, profileId, appId) + } + + /** + * Allows for refreshing the list of Pocket sponsored stories we have cached. + * + * @param appContext Android Context. Prefer sending application context to limit the possibility + * of even small leaks. + * @param fetchClient the HTTP client to use for network requests. + * @param profileId Unique profile identifier when using this feature. + * @param appId Unique identifier of the application using this feature. + * @param sponsoredStoriesParams Configuration containing parameters used to get the spoc content. + */ + internal inner class RefreshSponsoredStories( + @get:VisibleForTesting + internal val appContext: Context = this@SpocsUseCases.appContext, + @get:VisibleForTesting + internal val fetchClient: Client = this@SpocsUseCases.fetchClient, + @get:VisibleForTesting + internal val profileId: UUID = this@SpocsUseCases.profileId, + @get:VisibleForTesting + internal val appId: String = this@SpocsUseCases.appId, + @get:VisibleForTesting + internal val sponsoredStoriesParams: PocketStoriesRequestConfig = this@SpocsUseCases.sponsoredStoriesParams, + ) { + /** + * Do a full download from Pocket -> persist locally cycle for sponsored stories. + */ + suspend operator fun invoke(): Boolean { + val provider = getSpocsProvider(fetchClient, profileId, appId, sponsoredStoriesParams) + val response = provider.getSponsoredStories() + + if (response is Success) { + getSpocsRepository(appContext).addSpocs(response.data) + return true + } + + return false + } + } + + /** + * Allows for querying the list of available Pocket sponsored stories. + * + * @param context [Context] used for various system interactions and libraries initializations. + + */ + internal inner class GetSponsoredStories( + @get:VisibleForTesting + internal val context: Context = this@SpocsUseCases.appContext, + ) { + /** + * Do an internet query for a list of Pocket sponsored stories. + */ + suspend operator fun invoke(): List<PocketSponsoredStory> { + return getSpocsRepository(context).getAllSpocs() + } + } + + /** + * Allows for atomically updating the [PocketRecommendedStory.timesShown] property of some recommended stories. + * + * @param context [Context] used for various system interactions and libraries initializations. + */ + internal inner class RecordImpression( + @get:VisibleForTesting + internal val context: Context = this@SpocsUseCases.appContext, + ) { + /** + * Update how many times certain stories were shown to the user. + */ + suspend operator fun invoke(storiesShown: List<Int>) { + if (storiesShown.isNotEmpty()) { + getSpocsRepository(context).recordImpressions(storiesShown) + } + } + } + + /** + * Allows deleting all stored user data used for downloading sponsored stories. + * + * @param context [Context] used for various system interactions and libraries initializations. + * @param fetchClient the HTTP client to use for network requests. + * @param profileId Unique profile identifier previously used for downloading sponsored Pocket stories. + * @param appId Unique app identifier previously used for downloading sponsored Pocket stories. + * @param sponsoredStoriesParams Configuration containing parameters used to get the spoc content. + */ + internal inner class DeleteProfile( + @get:VisibleForTesting + internal val context: Context = this@SpocsUseCases.appContext, + @get:VisibleForTesting + internal val fetchClient: Client = this@SpocsUseCases.fetchClient, + @get:VisibleForTesting + internal val profileId: UUID = this@SpocsUseCases.profileId, + @get:VisibleForTesting + internal val appId: String = this@SpocsUseCases.appId, + @get:VisibleForTesting + internal val sponsoredStoriesParams: PocketStoriesRequestConfig = this@SpocsUseCases.sponsoredStoriesParams, + ) { + /** + * Delete all stored user data used for downloading personalized sponsored stories. + */ + suspend operator fun invoke(): Boolean { + val provider = getSpocsProvider(fetchClient, profileId, appId, sponsoredStoriesParams) + return when (provider.deleteProfile()) { + is Success -> { + getSpocsRepository(context).deleteAllSpocs() + true + } + is Failure -> { + // Don't attempt to delete locally persisted stories to prevent mismatching issues + // with profile deletion failing - applications still "showing it" but + // with no sponsored articles to show. + false + } + } + } + } + + @VisibleForTesting + internal fun getSpocsRepository(context: Context) = SpocsRepository(context) + + @VisibleForTesting + internal fun getSpocsProvider( + client: Client, + profileId: UUID, + appId: String, + sponsoredStoriesParams: PocketStoriesRequestConfig, + ) = + SpocsEndpoint.newInstance(client, profileId, appId, sponsoredStoriesParams) +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/api/ApiSpoc.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/api/ApiSpoc.kt new file mode 100644 index 0000000000..7f89df2ab7 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/api/ApiSpoc.kt @@ -0,0 +1,60 @@ +/* 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.pocket.spocs.api + +/** + * A Pocket sponsored as downloaded from the sponsored stories endpoint. + * + * @property id Unique id of this story. + * @property title the title of the story. + * @property url 3rd party url containing the original story. + * @property imageSrc a url to a still image representing the story. + * Contains a "resize" parameter in the form of "resize=w618-h310" allowing to get the image + * with a specific resolution and the CENTER_CROP ScaleType. + * @property sponsor 3rd party sponsor of this story, e.g. "NextAdvisor". + * @property shim Unique identifiers for when the user interacts with this story. + * @property priority Priority level in deciding which stories to be shown first. + * A lowest number means a higher priority. + * @property caps Story caps indented to control the maximum number of times the story should be shown. + */ +internal data class ApiSpoc( + val id: Int, + val title: String, + val url: String, + val imageSrc: String, + val sponsor: String, + val shim: ApiSpocShim, + val priority: Int, + val caps: ApiSpocCaps, +) + +/** + * Sponsored story unique identifiers intended to be used in telemetry. + * + * @property click Unique identifier for when the sponsored story is clicked. + * @property impression Unique identifier for when the user sees this sponsored story. + */ +internal data class ApiSpocShim( + val click: String, + val impression: String, +) + +/** + * Sponsored story caps indented to control the maximum number of times the story should be shown. + * + * @property lifetimeCount Lifetime maximum number of times this story should be shown. + * This is independent from the count based on [flightCount] and [flightPeriod] and must never be reset. + * @property flightCount Maximum number of times this story should be shown in [flightPeriod]. + * @property flightPeriod Period expressed as a number of seconds in which this story should be shown + * for at most [flightCount] times. + * Any time the period comes to an end the [flightCount] count should be restarted. + * Even if based on [flightCount] and [flightCount] this story can still be shown a couple more times + * if [lifetimeCount] was met then the story should not be shown anymore. + */ +internal data class ApiSpocCaps( + val lifetimeCount: Int, + val flightCount: Int, + val flightPeriod: Int, +) diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/api/SpocsEndpoint.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/api/SpocsEndpoint.kt new file mode 100644 index 0000000000..a17bebd6ba --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/api/SpocsEndpoint.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.pocket.spocs.api + +import androidx.annotation.VisibleForTesting +import androidx.annotation.WorkerThread +import mozilla.components.concept.fetch.Client +import mozilla.components.service.pocket.PocketStoriesRequestConfig +import mozilla.components.service.pocket.spocs.api.SpocsEndpoint.Companion.newInstance +import mozilla.components.service.pocket.stories.api.PocketEndpoint.Companion.newInstance +import mozilla.components.service.pocket.stories.api.PocketResponse +import java.util.UUID + +/** + * Makes requests to the sponsored stories API and returns the requested data. + * + * @see [newInstance] to retrieve an instance. + */ +internal class SpocsEndpoint internal constructor( + @get:VisibleForTesting internal val rawEndpoint: SpocsEndpointRaw, + private val jsonParser: SpocsJSONParser, +) : SpocsProvider { + + /** + * Download a new list of sponsored Pocket stories. + * + * If the API returns unexpectedly formatted results, these entries will be omitted and the rest of the items are + * returned. + * + * @return a [PocketResponse.Success] with the sponsored Pocket stories (list may be empty) + * or [PocketResponse.Failure] if the request didn't complete successfully. + */ + @WorkerThread + override suspend fun getSponsoredStories(): PocketResponse<List<ApiSpoc>> { + val response = rawEndpoint.getSponsoredStories() + val spocs = if (response.isNullOrBlank()) null else jsonParser.jsonToSpocs(response) + return PocketResponse.wrap(spocs) + } + + @WorkerThread + override suspend fun deleteProfile(): PocketResponse<Boolean> { + val response = rawEndpoint.deleteProfile() + return PocketResponse.wrap(response) + } + + companion object { + /** + * Returns a new instance of [SpocsEndpoint]. + * + * @param client the HTTP client to use for network requests. + * @param profileId Unique profile identifier which will be presented with sponsored stories. + * @param appId Unique identifier of the application using this feature. + * @param sponsoredStoriesParams Configuration containing parameters used to get the spoc content. + */ + fun newInstance( + client: Client, + profileId: UUID, + appId: String, + sponsoredStoriesParams: PocketStoriesRequestConfig, + ): SpocsEndpoint { + val rawEndpoint = SpocsEndpointRaw.newInstance(client, profileId, appId, sponsoredStoriesParams) + return SpocsEndpoint(rawEndpoint, SpocsJSONParser) + } + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/api/SpocsEndpointRaw.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/api/SpocsEndpointRaw.kt new file mode 100644 index 0000000000..8b1639a7fc --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/api/SpocsEndpointRaw.kt @@ -0,0 +1,178 @@ +/* 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.pocket.spocs.api + +import android.net.Uri +import androidx.annotation.VisibleForTesting +import androidx.annotation.WorkerThread +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.MutableHeaders +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Request.Body +import mozilla.components.concept.fetch.Request.Method +import mozilla.components.concept.fetch.Response +import mozilla.components.concept.fetch.isSuccess +import mozilla.components.service.pocket.BuildConfig +import mozilla.components.service.pocket.PocketStoriesRequestConfig +import mozilla.components.service.pocket.ext.fetchBodyOrNull +import mozilla.components.service.pocket.logger +import mozilla.components.service.pocket.spocs.api.SpocsEndpointRaw.Companion.newInstance +import mozilla.components.service.pocket.stories.api.PocketEndpointRaw.Companion.newInstance +import org.json.JSONObject +import java.io.IOException +import java.util.UUID + +private const val SPOCS_ENDPOINT_DEV_BASE_URL = "https://spocs.getpocket.dev/" +private const val SPOCS_ENDPOINT_PROD_BASE_URL = "https://spocs.getpocket.com/" +private const val SPOCS_ENDPOINT_DOWNLOAD_SPOCS_PATH = "spocs" +private const val SPOCS_ENDPOINT_DELETE_PROFILE_PATH = "user" +private const val SPOCS_PROXY_VERSION_KEY = "version" +private const val SPOCS_PROXY_VERSION_VALUE = 2 +private const val SPOCS_PROXY_PROFILE_KEY = "pocket_id" +private const val SPOCS_PROXY_APP_KEY = "consumer_key" +private const val SPOCS_PROXY_SITE_KEY = "site" +private const val SPOCS_PROXY_COUNTRY_KEY = "country" +private const val SPOCS_PROXY_CITY_KEY = "city" + +/** + * Makes requests to the Pocket endpoint and returns the raw JSON data. + * + * @see [SpocsEndpoint], which wraps this to make it more practical. + * @see [newInstance] to retrieve an instance. + */ +internal class SpocsEndpointRaw internal constructor( + @get:VisibleForTesting internal val client: Client, + @get:VisibleForTesting internal val profileId: UUID, + @get:VisibleForTesting internal val appId: String, + @get:VisibleForTesting internal val sponsoredStoriesParams: PocketStoriesRequestConfig, +) { + /** + * Gets the current sponsored stories recommendations from the Pocket server. + * + * @return The stories recommendations as a raw JSON string or null on error. + */ + @WorkerThread + fun getSponsoredStories(): String? { + val url = Uri.Builder() + .encodedPath(baseUrl + SPOCS_ENDPOINT_DOWNLOAD_SPOCS_PATH) + if (sponsoredStoriesParams.siteId.isNotBlank()) { + url.appendQueryParameter(SPOCS_PROXY_SITE_KEY, sponsoredStoriesParams.siteId) + } + if (sponsoredStoriesParams.country.isNotBlank()) { + url.appendQueryParameter(SPOCS_PROXY_COUNTRY_KEY, sponsoredStoriesParams.country) + } + if (sponsoredStoriesParams.city.isNotBlank()) { + url.appendQueryParameter(SPOCS_PROXY_CITY_KEY, sponsoredStoriesParams.city) + } + url.build() + + val request = Request( + url = url.toString(), + method = Method.POST, + headers = getRequestHeaders(), + body = getDownloadStoriesRequestBody(), + conservative = true, + ) + return client.fetchBodyOrNull(request) + } + + /** + * Request to delete all data stored on server about [profileId]. + * + * @return [Boolean] indicating whether the delete operation was successful or not. + */ + @WorkerThread + fun deleteProfile(): Boolean { + val url = Uri.Builder() + .encodedPath(baseUrl + SPOCS_ENDPOINT_DELETE_PROFILE_PATH) + if (sponsoredStoriesParams.siteId.isNotBlank()) { + url.appendQueryParameter(SPOCS_PROXY_SITE_KEY, sponsoredStoriesParams.siteId) + } + if (sponsoredStoriesParams.country.isNotBlank()) { + url.appendQueryParameter(SPOCS_PROXY_COUNTRY_KEY, sponsoredStoriesParams.country) + } + if (sponsoredStoriesParams.city.isNotBlank()) { + url.appendQueryParameter(SPOCS_PROXY_CITY_KEY, sponsoredStoriesParams.city) + } + url.build() + + val request = Request( + url = url.toString(), + method = Method.DELETE, + headers = getRequestHeaders(), + body = getDeleteProfileRequestBody(), + conservative = true, + ) + + val response: Response? = try { + client.fetch(request) + } catch (e: IOException) { + logger.debug("Network error", e) + null + } + + response?.close() + return response?.isSuccess ?: false + } + + private fun getRequestHeaders() = MutableHeaders( + "Content-Type" to "application/json; charset=UTF-8", + "Accept" to "*/*", + ) + + private fun getDownloadStoriesRequestBody(): Body { + val params = mapOf( + SPOCS_PROXY_VERSION_KEY to SPOCS_PROXY_VERSION_VALUE, + SPOCS_PROXY_PROFILE_KEY to profileId.toString(), + SPOCS_PROXY_APP_KEY to appId, + ) + + return Body(JSONObject(params).toString().byteInputStream()) + } + + private fun getDeleteProfileRequestBody(): Body { + val params = mapOf( + SPOCS_PROXY_PROFILE_KEY to profileId.toString(), + ) + + return Body(JSONObject(params).toString().byteInputStream()) + } + + companion object { + /** + * Returns a new instance of [SpocsEndpointRaw]. + * + * @param client HTTP client to use for network requests. + * @param profileId Unique profile identifier which will be presented with sponsored stories. + * @param appId Unique identifier of the application using this feature. + * @param sponsoredStoriesParams Configuration containing parameters used to get the spoc content. + */ + fun newInstance( + client: Client, + profileId: UUID, + appId: String, + sponsoredStoriesParams: PocketStoriesRequestConfig, + ): SpocsEndpointRaw { + return SpocsEndpointRaw(client, profileId, appId, sponsoredStoriesParams) + } + + /** + * Convenience for checking whether the current build is a debug build and overwriting this in tests. + */ + @VisibleForTesting + internal var isDebugBuild = BuildConfig.DEBUG + + /** + * Get the base url for sponsored stories specific to development or production. + */ + @VisibleForTesting + internal val baseUrl + get() = if (isDebugBuild) { + SPOCS_ENDPOINT_DEV_BASE_URL + } else { + SPOCS_ENDPOINT_PROD_BASE_URL + } + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/api/SpocsJSONParser.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/api/SpocsJSONParser.kt new file mode 100644 index 0000000000..b8fe1ea4e3 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/api/SpocsJSONParser.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.pocket.spocs.api + +import androidx.annotation.VisibleForTesting +import mozilla.components.service.pocket.logger +import mozilla.components.support.ktx.android.org.json.mapNotNull +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +@VisibleForTesting +internal const val KEY_ARRAY_SPOCS = "spocs" + +@VisibleForTesting +internal const val JSON_SPOC_SHIMS_KEY = "shim" + +@VisibleForTesting +internal const val JSON_SPOC_CAPS_KEY = "caps" + +@VisibleForTesting +internal const val JSON_SPOC_CAPS_LIFETIME_KEY = "lifetime" + +@VisibleForTesting +internal const val JSON_SPOC_CAPS_FLIGHT_KEY = "campaign" + +@VisibleForTesting +internal const val JSON_SPOC_CAPS_FLIGHT_COUNT_KEY = "count" + +@VisibleForTesting +internal const val JSON_SPOC_CAPS_FLIGHT_PERIOD_KEY = "period" +private const val JSON_SPOC_ID_KEY = "id" +private const val JSON_SPOC_TITLE_KEY = "title" +private const val JSON_SPOC_SPONSOR_KEY = "sponsor" +private const val JSON_SPOC_URL_KEY = "url" +private const val JSON_SPOC_IMAGE_SRC_KEY = "image_src" +private const val JSON_SPOC_SHIM_CLICK_KEY = "click" +private const val JSON_SPOC_SHIM_IMPRESSION_KEY = "impression" +private const val JSON_SPOC_PRIORITY = "priority" + +/** + * Holds functions that parse the JSON returned by the Pocket API and converts them to more usable Kotlin types. + */ +internal object SpocsJSONParser { + /** + * @return The stories, removing entries that are invalid, or null on error; the list will never be empty. + */ + fun jsonToSpocs(json: String): List<ApiSpoc>? = try { + val rawJSON = JSONObject(json) + val spocsJSON = rawJSON.getJSONArray(KEY_ARRAY_SPOCS) + val spocs = spocsJSON.mapNotNull(JSONArray::getJSONObject) { jsonToSpoc(it) } + + // We return null, rather than the empty list, because devs might forget to check an empty list. + spocs.ifEmpty { null } + } catch (e: JSONException) { + logger.warn("invalid JSON from the SPOCS endpoint", e) + null + } + + private fun jsonToSpoc(json: JSONObject): ApiSpoc? = try { + ApiSpoc( + id = json.getInt(JSON_SPOC_ID_KEY), + title = json.getString(JSON_SPOC_TITLE_KEY), + sponsor = json.getString(JSON_SPOC_SPONSOR_KEY), + url = json.getString(JSON_SPOC_URL_KEY), + imageSrc = json.getString(JSON_SPOC_IMAGE_SRC_KEY), + shim = jsonToShim(json.getJSONObject(JSON_SPOC_SHIMS_KEY)), + priority = json.getInt(JSON_SPOC_PRIORITY), + caps = jsonToCaps(json.getJSONObject(JSON_SPOC_CAPS_KEY)), + ) + } catch (e: JSONException) { + null + } + + private fun jsonToShim(json: JSONObject) = ApiSpocShim( + click = json.getString(JSON_SPOC_SHIM_CLICK_KEY), + impression = json.getString(JSON_SPOC_SHIM_IMPRESSION_KEY), + ) + + private fun jsonToCaps(json: JSONObject): ApiSpocCaps { + val flightCaps = json.getJSONObject(JSON_SPOC_CAPS_FLIGHT_KEY) + + return ApiSpocCaps( + lifetimeCount = json.getInt(JSON_SPOC_CAPS_LIFETIME_KEY), + flightCount = flightCaps.getInt(JSON_SPOC_CAPS_FLIGHT_COUNT_KEY), + flightPeriod = flightCaps.getInt(JSON_SPOC_CAPS_FLIGHT_PERIOD_KEY), + ) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/api/SpocsProvider.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/api/SpocsProvider.kt new file mode 100644 index 0000000000..dcb5819cd9 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/api/SpocsProvider.kt @@ -0,0 +1,27 @@ +/* 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.pocket.spocs.api + +import mozilla.components.service.pocket.stories.api.PocketResponse + +/** + * All possible operations related to SPocs - Sponsored Pocket stories. + */ +internal interface SpocsProvider { + /** + * Download new sponsored stories. + * + * @return [PocketResponse.Success] containing a list of sponsored stories or + * [PocketResponse.Failure] if the request didn't complete successfully. + */ + suspend fun getSponsoredStories(): PocketResponse<List<ApiSpoc>> + + /** + * Delete all data associated with [profileId]. + * + * @return [PocketResponse.Success] if the request completed successfully, [PocketResponse.Failure] otherwise. + */ + suspend fun deleteProfile(): PocketResponse<Boolean> +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/db/SpocEntity.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/db/SpocEntity.kt new file mode 100644 index 0000000000..02c68b7845 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/db/SpocEntity.kt @@ -0,0 +1,41 @@ +/* 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.pocket.spocs.db + +import androidx.room.Entity +import androidx.room.PrimaryKey +import mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase + +/** + * A sponsored Pocket story that is to be mapped to SQLite table. + * + * @property id Unique story id serving as the primary key of this entity. + * @property url URL where the original story can be read. + * @property title Title of the story. + * @property imageUrl URL of the hero image for this story. + * @property sponsor 3rd party sponsor of this story, e.g. "NextAdvisor". + * @property clickShim Telemetry identifier for when the sponsored story is clicked. + * @property impressionShim Telemetry identifier for when the sponsored story is seen by the user. + * @property priority Priority level in deciding which stories to be shown first. + * @property lifetimeCapCount Indicates how many times a sponsored story can be shown in total. + * @property flightCapCount Indicates how many times a sponsored story can be shown within a period. + * @property flightCapPeriod Indicates the period (number of seconds) in which at most [flightCapCount] + * stories can be shown. + */ +@Entity(tableName = PocketRecommendationsDatabase.TABLE_NAME_SPOCS) +internal data class SpocEntity( + @PrimaryKey + val id: Int, + val url: String, + val title: String, + val imageUrl: String, + val sponsor: String, + val clickShim: String, + val impressionShim: String, + val priority: Int, + val lifetimeCapCount: Int, + val flightCapCount: Int, + val flightCapPeriod: Int, +) diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/db/SpocImpressionEntity.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/db/SpocImpressionEntity.kt new file mode 100644 index 0000000000..25878bbf67 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/db/SpocImpressionEntity.kt @@ -0,0 +1,45 @@ +/* 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.pocket.spocs.db + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase + +/** + * One sponsored Pocket story impression. + * Allows to easily create a relation between a particular spoc identified by it's [SpocEntity.id] + * and any number of impressions. + * + * @property spocId [SpocEntity.id] that this serves as an impression of. + * Used as a foreign key allowing to only add impressions for other persisted spocs and + * automatically remove all impressions when the spoc they refer to is deleted. + * @property impressionId Unique id of this entity. Primary key. + * @property impressionDateInSeconds Epoch based timestamp expressed in seconds (from System.currentTimeMillis / 1000) + * for when the spoc identified by [spocId] was shown to the user. + */ +@Entity( + tableName = PocketRecommendationsDatabase.TABLE_NAME_SPOCS_IMPRESSIONS, + foreignKeys = [ + ForeignKey( + entity = SpocEntity::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("spocId"), + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index(value = ["spocId"], unique = false), + ], +) +internal data class SpocImpressionEntity( + val spocId: Int, +) { + @PrimaryKey(autoGenerate = true) + var impressionId: Int = 0 + var impressionDateInSeconds: Long = System.currentTimeMillis() / 1000 +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/db/SpocsDao.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/db/SpocsDao.kt new file mode 100644 index 0000000000..fadfec87c3 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/spocs/db/SpocsDao.kt @@ -0,0 +1,73 @@ +/* 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.pocket.spocs.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase +import java.util.concurrent.TimeUnit + +@Dao +internal interface SpocsDao { + @Transaction + suspend fun cleanOldAndInsertNewSpocs(spocs: List<SpocEntity>) { + val newSpocs = spocs.map { it.id } + val oldStoriesToDelete = getAllSpocs() + .filterNot { newSpocs.contains(it.id) } + + deleteSpocs(oldStoriesToDelete) + insertSpocs(spocs) + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) // Maybe some details changed + suspend fun insertSpocs(stories: List<SpocEntity>) + + @Transaction + suspend fun recordImpressions(stories: List<SpocImpressionEntity>) { + stories.forEach { + recordImpression(it.spocId, it.impressionDateInSeconds) + } + } + + /** + * INSERT OR IGNORE method needed to prevent against "FOREIGN KEY constraint failed" exceptions + * if clients try to insert new impressions spocs not existing anymore in the database in cases where + * a different list of spocs were downloaded but the client operates with stale in-memory data. + * + * @param targetSpocId The `id` of the [SpocEntity] to add a new impression for. + * A new impression will be persisted only if a story with the indicated [targetSpocId] currently exists. + * @param targetImpressionDateInSeconds The timestamp expressed in seconds from Epoch for this impression. + * Defaults to the current time expressed in seconds as get from `System.currentTimeMillis / 1000`. + */ + @Query( + "WITH newImpression(spocId, impressionDateInSeconds) AS (VALUES" + + "(:targetSpocId, :targetImpressionDateInSeconds)" + + ")" + + "INSERT INTO spocs_impressions(spocId, impressionDateInSeconds) " + + "SELECT impression.spocId, impression.impressionDateInSeconds " + + "FROM newImpression impression " + + "WHERE EXISTS (SELECT 1 FROM spocs spoc WHERE spoc.id = impression.spocId)", + ) + suspend fun recordImpression( + targetSpocId: Int, + targetImpressionDateInSeconds: Long = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()), + ) + + @Query("DELETE FROM ${PocketRecommendationsDatabase.TABLE_NAME_SPOCS}") + suspend fun deleteAllSpocs() + + @Delete + suspend fun deleteSpocs(stories: List<SpocEntity>) + + @Query("SELECT * FROM ${PocketRecommendationsDatabase.TABLE_NAME_SPOCS}") + suspend fun getAllSpocs(): List<SpocEntity> + + @Query("SELECT * FROM ${PocketRecommendationsDatabase.TABLE_NAME_SPOCS_IMPRESSIONS}") + suspend fun getSpocsImpressions(): List<SpocImpressionEntity> +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/PocketRecommendationsRepository.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/PocketRecommendationsRepository.kt new file mode 100644 index 0000000000..8bc6be41e1 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/PocketRecommendationsRepository.kt @@ -0,0 +1,47 @@ +/* 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.pocket.stories + +import android.content.Context +import androidx.annotation.VisibleForTesting +import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory +import mozilla.components.service.pocket.ext.toPartialTimeShownUpdate +import mozilla.components.service.pocket.ext.toPocketLocalStory +import mozilla.components.service.pocket.ext.toPocketRecommendedStory +import mozilla.components.service.pocket.stories.api.PocketApiStory +import mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase + +/** + * Wrapper over our local database. + * Allows for easy CRUD operations. + */ +internal class PocketRecommendationsRepository(context: Context) { + private val database: Lazy<PocketRecommendationsDatabase> = lazy { PocketRecommendationsDatabase.get(context) } + + @VisibleForTesting + internal val pocketRecommendationsDao by lazy { database.value.pocketRecommendationsDao() } + + /** + * Get the current locally persisted list of Pocket recommended articles. + */ + suspend fun getPocketRecommendedStories(): List<PocketRecommendedStory> { + return pocketRecommendationsDao.getPocketStories().map { it.toPocketRecommendedStory() } + } + + suspend fun updateShownPocketRecommendedStories(updatedStories: List<PocketRecommendedStory>) { + return pocketRecommendationsDao.updateTimesShown( + updatedStories.map { it.toPartialTimeShownUpdate() }, + ) + } + + /** + * Replace the current list of locally persisted Pocket recommended articles. + * + * @param stories The list of Pocket recommended articles to persist locally. + */ + suspend fun addAllPocketApiStories(stories: List<PocketApiStory>) { + pocketRecommendationsDao.cleanOldAndInsertNewPocketStories(stories.map { it.toPocketLocalStory() }) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/PocketStoriesUseCases.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/PocketStoriesUseCases.kt new file mode 100644 index 0000000000..5d50824524 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/PocketStoriesUseCases.kt @@ -0,0 +1,112 @@ +/* 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.pocket.stories + +import android.content.Context +import androidx.annotation.VisibleForTesting +import mozilla.components.concept.fetch.Client +import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory +import mozilla.components.service.pocket.stories.api.PocketEndpoint +import mozilla.components.service.pocket.stories.api.PocketResponse + +/** + * Possible actions regarding the list of recommended stories. + * + * @param appContext Android Context. Prefer sending application context to limit the possibility of even small leaks. + * @param fetchClient the HTTP client to use for network requests. + */ +internal class PocketStoriesUseCases( + private val appContext: Context, + private val fetchClient: Client, +) { + /** + * Download and persist an updated list of recommended stories. + */ + internal val refreshStories by lazy { RefreshPocketStories(appContext, fetchClient) } + + /** + * Get the list of available Pocket sponsored stories. + */ + internal val getStories by lazy { GetPocketStories(appContext) } + + /** + * Atomically update the number of impressions for a list of Pocket recommended stories. + */ + internal val updateTimesShown by lazy { UpdateStoriesTimesShown(appContext) } + + /** + * Allows for refreshing the list of pocket stories we have cached. + * + * @param appContext Android Context. Prefer sending application context to limit the possibility + * of even small leaks. + * @param fetchClient the HTTP client to use for network requests. + */ + internal inner class RefreshPocketStories( + @get:VisibleForTesting + internal val appContext: Context = this@PocketStoriesUseCases.appContext, + @get:VisibleForTesting + internal val fetchClient: Client = this@PocketStoriesUseCases.fetchClient, + ) { + /** + * Do a full download from Pocket -> persist locally cycle for recommended stories. + */ + suspend operator fun invoke(): Boolean { + val pocket = getPocketEndpoint(fetchClient) + val response = pocket.getRecommendedStories() + + if (response is PocketResponse.Success) { + getPocketRepository(appContext) + .addAllPocketApiStories(response.data) + return true + } + + return false + } + } + + /** + * Allows for querying the list of locally available Pocket recommended stories. + * + * @param context [Context] used for various system interactions and libraries initializations. + */ + internal inner class GetPocketStories( + @get:VisibleForTesting + internal val context: Context = this@PocketStoriesUseCases.appContext, + ) { + /** + * Returns the current locally persisted list of Pocket recommended stories. + */ + suspend operator fun invoke(): List<PocketRecommendedStory> { + return getPocketRepository(context) + .getPocketRecommendedStories() + } + } + + /** + * Allows for atomically updating the [PocketRecommendedStory.timesShown] property of some recommended stories. + * + * @param context [Context] used for various system interactions and libraries initializations. + */ + internal inner class UpdateStoriesTimesShown( + @get:VisibleForTesting + internal val context: Context = this@PocketStoriesUseCases.appContext, + ) { + /** + * Update how many times certain stories were shown to the user. + */ + suspend operator fun invoke(storiesShown: List<PocketRecommendedStory>) { + if (storiesShown.isNotEmpty()) { + getPocketRepository(context) + .updateShownPocketRecommendedStories(storiesShown) + } + } + } + + @VisibleForTesting + internal fun getPocketRepository(context: Context) = PocketRecommendationsRepository(context) + + @VisibleForTesting + internal fun getPocketEndpoint(client: Client) = PocketEndpoint.newInstance(client) +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/api/PocketApiStory.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/api/PocketApiStory.kt new file mode 100644 index 0000000000..203dd9c35d --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/api/PocketApiStory.kt @@ -0,0 +1,28 @@ +/* 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.pocket.stories.api + +internal const val STRING_NOT_FOUND_DEFAULT_VALUE = "" +internal const val INT_NOT_FOUND_DEFAULT_VALUE = -1 + +/** + * A Pocket recommended story as downloaded from home-recommendations endpoint + * + * @property title the title of the story. + * @property url a "pocket.co" shortlink for the original story's page. + * @property imageUrl a url to a still image representing the story. + * @property publisher optional publisher name/domain, e.g. "The New Yorker" / "nationalgeographic.co.uk"". + * **May be empty**. + * @property category topic of interest under which similar stories are grouped. **May be empty**. + * @property timeToRead inferred time needed to read the entire story. **May be -1**. + */ +internal data class PocketApiStory( + val title: String, + val url: String, + val imageUrl: String, + val publisher: String, + val category: String, + val timeToRead: Int, +) diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/api/PocketEndpoint.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/api/PocketEndpoint.kt new file mode 100644 index 0000000000..d447293e52 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/api/PocketEndpoint.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.pocket.stories.api + +import androidx.annotation.VisibleForTesting +import androidx.annotation.WorkerThread +import mozilla.components.concept.fetch.Client +import mozilla.components.service.pocket.stories.api.PocketEndpoint.Companion.newInstance + +/** + * Makes requests to the Pocket API and returns the requested data. + * + * @see [newInstance] to retrieve an instance. + */ +internal class PocketEndpoint internal constructor( + @get:VisibleForTesting internal val rawEndpoint: PocketEndpointRaw, + private val jsonParser: PocketJSONParser, +) { + + /** + * Gets a response, filled with the Pocket stories recommendations from the Pocket API server on success. + * + * If the API returns unexpectedly formatted results, these entries will be omitted and the rest of the items are + * returned. + * + * @return a [PocketResponse.Success] with the Pocket stories recommendations (the list will never be empty) + * or, on error, a [PocketResponse.Failure]. + */ + @WorkerThread + fun getRecommendedStories(): PocketResponse<List<PocketApiStory>> { + val response = rawEndpoint.getRecommendedStories() + val stories = response?.let { jsonParser.jsonToPocketApiStories(it) } + return PocketResponse.wrap(stories) + } + + companion object { + /** + * Returns a new instance of [PocketEndpoint]. + * + * @param client the HTTP client to use for network requests. + */ + fun newInstance(client: Client): PocketEndpoint { + val rawEndpoint = PocketEndpointRaw.newInstance(client) + return PocketEndpoint(rawEndpoint, PocketJSONParser()) + } + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/api/PocketEndpointRaw.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/api/PocketEndpointRaw.kt new file mode 100644 index 0000000000..bba2adcfbc --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/api/PocketEndpointRaw.kt @@ -0,0 +1,52 @@ +/* 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.pocket.stories.api + +import androidx.annotation.VisibleForTesting +import androidx.annotation.WorkerThread +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.service.pocket.ext.fetchBodyOrNull +import mozilla.components.service.pocket.stories.api.PocketEndpointRaw.Companion.newInstance + +/** + * Makes requests to the Pocket endpoint and returns the raw JSON data. + * + * @see [PocketEndpoint], which wraps this to make it more practical. + * @see [newInstance] to retrieve an instance. + */ +internal class PocketEndpointRaw internal constructor( + @get:VisibleForTesting internal val client: Client, +) { + /** + * Gets the current stories recommendations from the Pocket server. + * + * @return The stories recommendations as a raw JSON string or null on error. + */ + @WorkerThread + fun getRecommendedStories(): String? = makeRequest() + + /** + * @return The requested JSON as a String or null on error. + */ + @WorkerThread // synchronous request. + private fun makeRequest(): String? { + val request = Request(pocketEndpointUrl, conservative = true) + return client.fetchBodyOrNull(request) + } + + companion object { + private const val pocketEndpointUrl = "https://firefox-android-home-recommendations.getpocket.com/" + + /** + * Returns a new instance of [PocketEndpointRaw]. + * + * @param client the HTTP client to use for network requests. + */ + fun newInstance(client: Client): PocketEndpointRaw { + return PocketEndpointRaw(client) + } + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/api/PocketJSONParser.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/api/PocketJSONParser.kt new file mode 100644 index 0000000000..a2de1017dd --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/api/PocketJSONParser.kt @@ -0,0 +1,78 @@ +/* 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.pocket.stories.api + +import androidx.annotation.VisibleForTesting +import mozilla.components.service.pocket.logger +import mozilla.components.support.ktx.android.org.json.mapNotNull +import mozilla.components.support.ktx.android.org.json.tryGetInt +import mozilla.components.support.ktx.android.org.json.tryGetString +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +private const val JSON_STORY_TITLE_KEY = "title" +private const val JSON_STORY_URL_KEY = "url" +private const val JSON_STORY_IMAGE_URL_KEY = "imageUrl" +private const val JSON_STORY_PUBLISHER_KEY = "publisher" +private const val JSON_STORY_CATEGORY_KEY = "category" +private const val JSON_STORY_TIME_TO_READ_KEY = "timeToRead" + +/** + * Holds functions that parse the JSON returned by the Pocket API and converts them to more usable Kotlin types. + */ +internal class PocketJSONParser { + /** + * @return The stories, removing entries that are invalid, or null on error; the list will never be empty. + */ + fun jsonToPocketApiStories(json: String): List<PocketApiStory>? = try { + val rawJSON = JSONObject(json) + val storiesJSON = rawJSON.getJSONArray(KEY_ARRAY_ITEMS) + val stories = storiesJSON.mapNotNull(JSONArray::getJSONObject) { jsonToPocketApiStory(it) } + + // We return null, rather than the empty list, because devs might forget to check an empty list. + stories.ifEmpty { null } + } catch (e: JSONException) { + logger.warn("invalid JSON from the Pocket endpoint", e) + null + } + + private fun jsonToPocketApiStory(json: JSONObject): PocketApiStory? = try { + val title = json.tryGetString(JSON_STORY_TITLE_KEY) + val url = json.tryGetString(JSON_STORY_URL_KEY) + val imageUrl = json.tryGetString(JSON_STORY_IMAGE_URL_KEY) + + // These three properties are required for any valid recommendation. + if (title == null || url == null || imageUrl == null) { + null + } else { + PocketApiStory( + title = title, + url = url, + imageUrl = imageUrl, + // The following three properties are optional. + publisher = json.tryGetString(JSON_STORY_PUBLISHER_KEY) + ?: STRING_NOT_FOUND_DEFAULT_VALUE, + category = json.tryGetString(JSON_STORY_CATEGORY_KEY) + ?: STRING_NOT_FOUND_DEFAULT_VALUE, + timeToRead = json.tryGetInt(JSON_STORY_TIME_TO_READ_KEY) + ?: INT_NOT_FOUND_DEFAULT_VALUE, + ) + } + } catch (e: JSONException) { + null + } + + companion object { + @VisibleForTesting const val KEY_ARRAY_ITEMS = "recommendations" + + /** + * Returns a new instance of [PocketJSONParser]. + */ + fun newInstance(): PocketJSONParser { + return PocketJSONParser() + } + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/api/PocketResponse.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/api/PocketResponse.kt new file mode 100644 index 0000000000..23e8fdcf04 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/api/PocketResponse.kt @@ -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/. */ + +package mozilla.components.service.pocket.stories.api + +/** + * A response from the Pocket API: the subclasses determine the type of the result and contain usable data. + */ +internal sealed class PocketResponse<T> { + + /** + * A successful response from the Pocket API. + * + * @param data The data returned from the Pocket API. + */ + data class Success<T> internal constructor(val data: T) : PocketResponse<T>() + + /** + * A failure response from the Pocket API. + */ + class Failure<T> internal constructor() : PocketResponse<T>() + + companion object { + + /** + * Wraps the given [target] in a [PocketResponse]: if [target] is + * - null, then Failure + * - a Collection and empty, then Failure + * - a String and empty, then Failure + * - a Boolean and false, then Failure + * - otherwise, Success + */ + internal fun <T : Any> wrap(target: T?): PocketResponse<T> = when (target) { + null -> Failure() + is Collection<*> -> if (target.isEmpty()) Failure() else Success(target) + is String -> if (target.isBlank()) Failure() else Success(target) + is Boolean -> if (target == false) Failure() else Success(target) + else -> Success(target) + } + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/db/PocketRecommendationsDao.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/db/PocketRecommendationsDao.kt new file mode 100644 index 0000000000..c493e04dd6 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/db/PocketRecommendationsDao.kt @@ -0,0 +1,46 @@ +/* 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.pocket.stories.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update + +@Dao +internal interface PocketRecommendationsDao { + /** + * Add new stories to the database. + * Stories already existing will not be updated in any way. + * Already persisted stories but not present in [stories] will be removed from the database. + * + * @param stories new list of [PocketStoryEntity]s to replace the currently persisted ones. + */ + @Transaction + suspend fun cleanOldAndInsertNewPocketStories(stories: List<PocketStoryEntity>) { + // If any url changed that story is obsolete and should be deleted. + val newStoriesUrls = stories.map { it.url to it.imageUrl } + val oldStoriesToDelete = getPocketStories() + .filterNot { newStoriesUrls.contains(it.url to it.imageUrl) } + delete(oldStoriesToDelete) + + insertPocketStories(stories) + } + + @Update(entity = PocketStoryEntity::class) + suspend fun updateTimesShown(updatedStories: List<PocketLocalStoryTimesShown>) + + @Query("SELECT * FROM ${PocketRecommendationsDatabase.TABLE_NAME_STORIES}") + suspend fun getPocketStories(): List<PocketStoryEntity> + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertPocketStories(stories: List<PocketStoryEntity>) + + @Delete + suspend fun delete(stories: List<PocketStoryEntity>) +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/db/PocketRecommendationsDatabase.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/db/PocketRecommendationsDatabase.kt new file mode 100644 index 0000000000..5a3e4f0efe --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/db/PocketRecommendationsDatabase.kt @@ -0,0 +1,185 @@ +/* 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.pocket.stories.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import mozilla.components.service.pocket.spocs.db.SpocEntity +import mozilla.components.service.pocket.spocs.db.SpocImpressionEntity +import mozilla.components.service.pocket.spocs.db.SpocsDao + +/** + * Internal database for storing Pocket items. + */ +@Database( + entities = [ + PocketStoryEntity::class, + SpocEntity::class, + SpocImpressionEntity::class, + ], + version = 4, +) +internal abstract class PocketRecommendationsDatabase : RoomDatabase() { + abstract fun pocketRecommendationsDao(): PocketRecommendationsDao + abstract fun spocsDao(): SpocsDao + + companion object { + private const val DATABASE_NAME = "pocket_recommendations" + const val TABLE_NAME_STORIES = "stories" + const val TABLE_NAME_SPOCS = "spocs" + const val TABLE_NAME_SPOCS_IMPRESSIONS = "spocs_impressions" + + @Volatile + private var instance: PocketRecommendationsDatabase? = null + + @Synchronized + fun get(context: Context): PocketRecommendationsDatabase { + instance?.let { return it } + + return Room.databaseBuilder( + context, + PocketRecommendationsDatabase::class.java, + DATABASE_NAME, + ) + .addMigrations( + Migrations.migration_1_2, + Migrations.migration_2_3, + Migrations.migration_1_3, + Migrations.migration_3_4, + ) + .build().also { + instance = it + } + } + } +} + +internal object Migrations { + val migration_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS " + + "`${PocketRecommendationsDatabase.TABLE_NAME_SPOCS}` (" + + "`url` TEXT NOT NULL, " + + "`title` TEXT NOT NULL, " + + "`imageUrl` TEXT NOT NULL, " + + "`sponsor` TEXT NOT NULL, " + + "`clickShim` TEXT NOT NULL, " + + "`impressionShim` TEXT NOT NULL, " + + "PRIMARY KEY(`url`)" + + ")", + ) + } + } + + /** + * Migration for when adding support for pacing sponsored stories. + */ + val migration_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + // There are many new columns added. Drop the old table allowing to start fresh. + // This migration is expected to only be needed in debug builds + // with the feature not being live in any Fenix release. + db.execSQL( + "DROP TABLE ${PocketRecommendationsDatabase.TABLE_NAME_SPOCS}", + ) + + db.createNewSpocsTables() + } + } + + /** + * Migration for when adding sponsored stories along with pacing support. + */ + val migration_1_3 = object : Migration(1, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.createNewSpocsTables() + } + } + + /** + * Migration for when adding a new index to the spoc impression entity. + */ + val migration_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + // Rename the old tables to allow creating new ones + db.execSQL( + "ALTER TABLE `${PocketRecommendationsDatabase.TABLE_NAME_SPOCS}` " + + "RENAME TO temp_spocs", + ) + db.execSQL( + "ALTER TABLE `${PocketRecommendationsDatabase.TABLE_NAME_SPOCS_IMPRESSIONS}` " + + "RENAME TO temp_spocs_impressions", + ) + + // Create new tables with the new schema + db.createNewSpocsTables() + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_spocs_impressions_spocId` " + + "ON `${PocketRecommendationsDatabase.TABLE_NAME_SPOCS_IMPRESSIONS}` (`spocId`)", + ) + + // Copy the old data to the new tables + db.execSQL( + "INSERT INTO " + + "'${PocketRecommendationsDatabase.TABLE_NAME_SPOCS}' (" + + "id, url, title, imageUrl, sponsor, clickShim, impressionShim, " + + "priority, lifetimeCapCount, flightCapCount, flightCapPeriod" + + ") SELECT " + + "id, url, title, imageUrl, sponsor, clickShim, impressionShim, " + + "priority, lifetimeCapCount, flightCapCount, flightCapPeriod " + + "FROM temp_spocs", + ) + db.execSQL( + "INSERT INTO " + + "'${PocketRecommendationsDatabase.TABLE_NAME_SPOCS_IMPRESSIONS}' (" + + "spocId, impressionId, impressionDateInSeconds" + + ") SELECT " + + "spocId, impressionId, impressionDateInSeconds " + + "FROM temp_spocs_impressions", + ) + + // Cleanup + db.execSQL("DROP TABLE temp_spocs") + db.execSQL("DROP TABLE temp_spocs_impressions") + } + } + + private fun SupportSQLiteDatabase.createNewSpocsTables() { + execSQL( + "CREATE TABLE IF NOT EXISTS " + + "`${PocketRecommendationsDatabase.TABLE_NAME_SPOCS}` (" + + "`id` INTEGER NOT NULL, " + + "`url` TEXT NOT NULL, " + + "`title` TEXT NOT NULL, " + + "`imageUrl` TEXT NOT NULL, " + + "`sponsor` TEXT NOT NULL, " + + "`clickShim` TEXT NOT NULL, " + + "`impressionShim` TEXT NOT NULL, " + + "`priority` INTEGER NOT NULL, " + + "`lifetimeCapCount` INTEGER NOT NULL, " + + "`flightCapCount` INTEGER NOT NULL, " + + "`flightCapPeriod` INTEGER NOT NULL, " + + "PRIMARY KEY(`id`)" + + ")", + ) + + execSQL( + "CREATE TABLE IF NOT EXISTS " + + "`${PocketRecommendationsDatabase.TABLE_NAME_SPOCS_IMPRESSIONS}` (" + + "`spocId` INTEGER NOT NULL, " + + "`impressionId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`impressionDateInSeconds` INTEGER NOT NULL, " + + "FOREIGN KEY(`spocId`) " + + "REFERENCES `spocs`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE " + + ")", + ) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/db/PocketStoryEntity.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/db/PocketStoryEntity.kt new file mode 100644 index 0000000000..885978f801 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/stories/db/PocketStoryEntity.kt @@ -0,0 +1,31 @@ +/* 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.pocket.stories.db + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * A Pocket recommended story that is to be mapped to SQLite table. + */ +@Entity(tableName = PocketRecommendationsDatabase.TABLE_NAME_STORIES) +internal data class PocketStoryEntity( + @PrimaryKey + val url: String, + val title: String, + val imageUrl: String, + val publisher: String, + val category: String, + val timeToRead: Int, + val timesShown: Long, +) + +/** + * A [PocketStoryEntity] only containing data about the [timesShown] property allowing for quick updates. + */ +internal data class PocketLocalStoryTimesShown( + val url: String, + val timesShown: Long, +) diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/update/DeleteSpocsProfileWorker.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/update/DeleteSpocsProfileWorker.kt new file mode 100644 index 0000000000..e563cba564 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/update/DeleteSpocsProfileWorker.kt @@ -0,0 +1,36 @@ +/* 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.pocket.update + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import mozilla.components.service.pocket.GlobalDependencyProvider + +/** + * WorkManager Worker used for deleting the profile used for downloading Pocket sponsored stories. + */ +internal class DeleteSpocsProfileWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + return withContext(Dispatchers.IO) { + if (GlobalDependencyProvider.SponsoredStories.useCases?.deleteProfile?.invoke() == true) { + Result.success() + } else { + Result.retry() + } + } + } + + internal companion object { + const val DELETE_SPOCS_PROFILE_WORK_TAG = + "mozilla.components.feature.pocket.spocs.profile.delete.work.tag" + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/update/PocketStoriesRefreshScheduler.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/update/PocketStoriesRefreshScheduler.kt new file mode 100644 index 0000000000..7cdc7b8daf --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/update/PocketStoriesRefreshScheduler.kt @@ -0,0 +1,64 @@ +/* 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.pocket.update + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequest +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import mozilla.components.service.pocket.PocketStoriesConfig +import mozilla.components.service.pocket.logger +import mozilla.components.service.pocket.update.RefreshPocketWorker.Companion.REFRESH_WORK_TAG +import mozilla.components.support.base.worker.Frequency + +/** + * Class used to schedule Pocket recommended stories refresh. + */ +internal class PocketStoriesRefreshScheduler( + private val pocketStoriesConfig: PocketStoriesConfig, +) { + internal fun schedulePeriodicRefreshes(context: Context) { + logger.info("Scheduling pocket recommendations background refresh") + + val refreshWork = createPeriodicWorkerRequest( + frequency = pocketStoriesConfig.frequency, + ) + + getWorkManager(context) + .enqueueUniquePeriodicWork(REFRESH_WORK_TAG, ExistingPeriodicWorkPolicy.KEEP, refreshWork) + } + + internal fun stopPeriodicRefreshes(context: Context) { + getWorkManager(context) + .cancelAllWorkByTag(REFRESH_WORK_TAG) + } + + @VisibleForTesting + internal fun createPeriodicWorkerRequest( + frequency: Frequency, + ): PeriodicWorkRequest { + val constraints = getWorkerConstrains() + + return PeriodicWorkRequestBuilder<RefreshPocketWorker>( + frequency.repeatInterval, + frequency.repeatIntervalTimeUnit, + ).apply { + setConstraints(constraints) + addTag(REFRESH_WORK_TAG) + }.build() + } + + @VisibleForTesting + internal fun getWorkerConstrains() = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + @VisibleForTesting + internal fun getWorkManager(context: Context) = WorkManager.getInstance(context) +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/update/RefreshPocketWorker.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/update/RefreshPocketWorker.kt new file mode 100644 index 0000000000..c5790598b9 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/update/RefreshPocketWorker.kt @@ -0,0 +1,36 @@ +/* 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.pocket.update + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import mozilla.components.service.pocket.GlobalDependencyProvider + +/** + * WorkManager Worker used for downloading and persisting locally a new list of Pocket recommended stories. + */ +internal class RefreshPocketWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + return withContext(Dispatchers.IO) { + if (GlobalDependencyProvider.RecommendedStories.useCases?.refreshStories?.invoke() == true) { + Result.success() + } else { + Result.retry() + } + } + } + + internal companion object { + const val REFRESH_WORK_TAG = + "mozilla.components.feature.pocket.recommendations.refresh.work.tag" + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/update/RefreshSpocsWorker.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/update/RefreshSpocsWorker.kt new file mode 100644 index 0000000000..def14f9c16 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/update/RefreshSpocsWorker.kt @@ -0,0 +1,36 @@ +/* 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.pocket.update + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import mozilla.components.service.pocket.GlobalDependencyProvider + +/** + * WorkManager Worker used for downloading and persisting locally a new list of Pocket recommended stories. + */ +internal class RefreshSpocsWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + return withContext(Dispatchers.IO) { + if (GlobalDependencyProvider.SponsoredStories.useCases?.refreshStories?.invoke() == true) { + Result.success() + } else { + Result.retry() + } + } + } + + internal companion object { + const val REFRESH_SPOCS_WORK_TAG = + "mozilla.components.feature.pocket.spocs.refresh.work.tag" + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/update/SpocsRefreshScheduler.kt b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/update/SpocsRefreshScheduler.kt new file mode 100644 index 0000000000..01ffd998a0 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/java/mozilla/components/service/pocket/update/SpocsRefreshScheduler.kt @@ -0,0 +1,94 @@ +/* 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.pocket.update + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequest +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import mozilla.components.service.pocket.PocketStoriesConfig +import mozilla.components.service.pocket.logger +import mozilla.components.service.pocket.update.DeleteSpocsProfileWorker.Companion.DELETE_SPOCS_PROFILE_WORK_TAG +import mozilla.components.service.pocket.update.RefreshSpocsWorker.Companion.REFRESH_SPOCS_WORK_TAG +import mozilla.components.support.base.worker.Frequency + +/** + * Class used to schedule Pocket recommended stories refresh. + */ +internal class SpocsRefreshScheduler( + private val pocketStoriesConfig: PocketStoriesConfig, +) { + internal fun schedulePeriodicRefreshes(context: Context) { + logger.info("Scheduling sponsored stories background refresh") + + val refreshWork = createPeriodicRefreshWorkerRequest( + frequency = pocketStoriesConfig.sponsoredStoriesRefreshFrequency, + ) + + getWorkManager(context) + .enqueueUniquePeriodicWork(REFRESH_SPOCS_WORK_TAG, ExistingPeriodicWorkPolicy.KEEP, refreshWork) + } + + internal fun stopPeriodicRefreshes(context: Context) { + getWorkManager(context) + .cancelAllWorkByTag(REFRESH_SPOCS_WORK_TAG) + } + + internal fun scheduleProfileDeletion(context: Context) { + logger.info("Scheduling sponsored stories profile deletion") + + val deleteProfileWork = createOneTimeProfileDeletionWorkerRequest() + + getWorkManager(context) + .enqueueUniqueWork(DELETE_SPOCS_PROFILE_WORK_TAG, ExistingWorkPolicy.KEEP, deleteProfileWork) + } + + internal fun stopProfileDeletion(context: Context) { + getWorkManager(context) + .cancelAllWorkByTag(DELETE_SPOCS_PROFILE_WORK_TAG) + } + + @VisibleForTesting + internal fun createOneTimeProfileDeletionWorkerRequest(): OneTimeWorkRequest { + val constraints = getWorkerConstrains() + + return OneTimeWorkRequestBuilder<DeleteSpocsProfileWorker>() + .apply { + setConstraints(constraints) + addTag(DELETE_SPOCS_PROFILE_WORK_TAG) + } + .build() + } + + @VisibleForTesting + internal fun createPeriodicRefreshWorkerRequest( + frequency: Frequency, + ): PeriodicWorkRequest { + val constraints = getWorkerConstrains() + + return PeriodicWorkRequestBuilder<RefreshSpocsWorker>( + frequency.repeatInterval, + frequency.repeatIntervalTimeUnit, + ).apply { + setConstraints(constraints) + addTag(REFRESH_SPOCS_WORK_TAG) + }.build() + } + + @VisibleForTesting + internal fun getWorkerConstrains() = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + @VisibleForTesting + internal fun getWorkManager(context: Context) = WorkManager.getInstance(context) +} diff --git a/mobile/android/android-components/components/service/pocket/src/main/res/values/ids.xml b/mobile/android/android-components/components/service/pocket/src/main/res/values/ids.xml new file mode 100644 index 0000000000..7a05fcff1a --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/main/res/values/ids.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/. --> +<resources> + <item name="payload_pocket_token" type="id"/> + <item name="payload_pocket_user_agent" type="id"/> + <item name="payload_pocket_items_count" type="id"/> + <item name="payload_pocket_items_locale" type="id"/> +</resources> diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/GlobalDependencyProviderTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/GlobalDependencyProviderTest.kt new file mode 100644 index 0000000000..a87f46887d --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/GlobalDependencyProviderTest.kt @@ -0,0 +1,50 @@ +/* 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.pocket + +import mozilla.components.service.pocket.spocs.SpocsUseCases +import mozilla.components.service.pocket.stories.PocketStoriesUseCases +import mozilla.components.support.test.mock +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class GlobalDependencyProviderTest { + @Test + fun `GIVEN RecommendedStories WHEN initializing THEN store the provided arguments`() { + val useCases: PocketStoriesUseCases = mock() + + GlobalDependencyProvider.RecommendedStories.initialize(useCases) + + assertSame(useCases, GlobalDependencyProvider.RecommendedStories.useCases) + } + + @Test + fun `GIVEN RecommendedStories WHEN resetting THEN clear all current state`() { + GlobalDependencyProvider.RecommendedStories.initialize(mock()) + + GlobalDependencyProvider.RecommendedStories.reset() + + assertNull(GlobalDependencyProvider.RecommendedStories.useCases) + } + + @Test + fun `GIVEN SponsoredStories WHEN initializing THEN store the provided arguments`() { + val useCases: SpocsUseCases = mock() + + GlobalDependencyProvider.SponsoredStories.initialize(useCases) + + assertSame(useCases, GlobalDependencyProvider.SponsoredStories.useCases) + } + + @Test + fun `GIVEN SponsoredStories WHEN resetting THEN clear all current state`() { + GlobalDependencyProvider.SponsoredStories.initialize(mock()) + + GlobalDependencyProvider.SponsoredStories.reset() + + assertNull(GlobalDependencyProvider.SponsoredStories.useCases) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/PocketStoriesConfigTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/PocketStoriesConfigTest.kt new file mode 100644 index 0000000000..2e8f504f4a --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/PocketStoriesConfigTest.kt @@ -0,0 +1,62 @@ +/* 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.pocket + +import mozilla.components.service.pocket.helpers.assertClassVisibility +import mozilla.components.support.base.worker.Frequency +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import kotlin.reflect.KVisibility + +class PocketStoriesConfigTest { + @Test + fun `GIVEN a PocketStoriesConfig THEN its visibility is internal`() { + assertClassVisibility(PocketStoriesConfig::class, KVisibility.PUBLIC) + } + + @Test + fun `WHEN instantiating a PocketStoriesConfig THEN frequency has a default value`() { + val config = PocketStoriesConfig(mock()) + + val defaultFrequency = Frequency(DEFAULT_REFRESH_INTERVAL, DEFAULT_REFRESH_TIMEUNIT) + assertEquals(defaultFrequency.repeatInterval, config.frequency.repeatInterval) + assertEquals(defaultFrequency.repeatIntervalTimeUnit, config.frequency.repeatIntervalTimeUnit) + } + + @Test + fun `WHEN instantiating a PocketStoriesConfig THEN sponsored stories refresh frequency has a default value`() { + val config = PocketStoriesConfig(mock()) + + val defaultFrequency = Frequency( + DEFAULT_SPONSORED_STORIES_REFRESH_INTERVAL, + DEFAULT_SPONSORED_STORIES_REFRESH_TIMEUNIT, + ) + assertEquals(defaultFrequency.repeatInterval, config.sponsoredStoriesRefreshFrequency.repeatInterval) + assertEquals(defaultFrequency.repeatIntervalTimeUnit, config.sponsoredStoriesRefreshFrequency.repeatIntervalTimeUnit) + } + + @Test + fun `WHEN instantiating a PocketStoriesConfig THEN profile is by default null`() { + val config = PocketStoriesConfig(mock()) + + assertNull(config.profile) + } + + @Test + fun `GIVEN a Frequency THEN its visibility is internal`() { + assertClassVisibility(Frequency::class, KVisibility.PUBLIC) + } + + @Test + fun `WHEN instantiating a PocketStoriesConfig THEN sponsoredStoriesParams default value is used`() { + val config = PocketStoriesConfig(mock()) + + assertEquals(DEFAULT_SPONSORED_STORIES_SITE_ID, config.sponsoredStoriesParams.siteId) + assertEquals("", config.sponsoredStoriesParams.country) + assertEquals("", config.sponsoredStoriesParams.city) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/PocketStoriesServiceTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/PocketStoriesServiceTest.kt new file mode 100644 index 0000000000..04c7a23939 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/PocketStoriesServiceTest.kt @@ -0,0 +1,229 @@ +/* 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.pocket + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.concept.fetch.Client +import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory +import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory +import mozilla.components.service.pocket.helpers.assertConstructorsVisibility +import mozilla.components.service.pocket.spocs.SpocsUseCases +import mozilla.components.service.pocket.spocs.SpocsUseCases.GetSponsoredStories +import mozilla.components.service.pocket.spocs.SpocsUseCases.RecordImpression +import mozilla.components.service.pocket.stories.PocketStoriesUseCases +import mozilla.components.service.pocket.stories.PocketStoriesUseCases.GetPocketStories +import mozilla.components.service.pocket.stories.PocketStoriesUseCases.UpdateStoriesTimesShown +import mozilla.components.support.test.any +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.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import java.util.UUID +import kotlin.reflect.KVisibility + +@ExperimentalCoroutinesApi // for runTest +@RunWith(AndroidJUnit4::class) +class PocketStoriesServiceTest { + private val storiesUseCases: PocketStoriesUseCases = mock() + private val spocsUseCases: SpocsUseCases = mock() + private val service = PocketStoriesService(testContext, PocketStoriesConfig(mock())).also { + it.storiesRefreshScheduler = mock() + it.spocsRefreshscheduler = mock() + it.storiesUseCases = storiesUseCases + it.spocsUseCases = spocsUseCases + } + + @After + fun teardown() { + GlobalDependencyProvider.SponsoredStories.reset() + GlobalDependencyProvider.RecommendedStories.reset() + } + + @Test + fun `GIVEN PocketStoriesService THEN it should be publicly available`() { + assertConstructorsVisibility(PocketStoriesConfig::class, KVisibility.PUBLIC) + } + + @Test + fun `GIVEN PocketStoriesService WHEN startPeriodicStoriesRefresh THEN persist dependencies and schedule stories refresh`() { + service.startPeriodicStoriesRefresh() + + assertNotNull(GlobalDependencyProvider.RecommendedStories.useCases) + verify(service.storiesRefreshScheduler).schedulePeriodicRefreshes(any()) + } + + @Test + fun `GIVEN PocketStoriesService WHEN stopPeriodicStoriesRefresh THEN stop refreshing stories and clear dependencies`() { + service.stopPeriodicStoriesRefresh() + + verify(service.storiesRefreshScheduler).stopPeriodicRefreshes(any()) + assertNull(GlobalDependencyProvider.RecommendedStories.useCases) + } + + @Test + fun `GIVEN PocketStoriesService is initialized with a valid profile WHEN called to start periodic refreshes THEN persist dependencies, cancel profile deletion and schedule stories refresh`() { + val client: Client = mock() + val profileId = UUID.randomUUID() + val appId = "test" + val service = PocketStoriesService( + context = testContext, + pocketStoriesConfig = PocketStoriesConfig( + client = client, + profile = Profile( + profileId = profileId, + appId = appId, + ), + ), + ).apply { + spocsRefreshscheduler = mock() + } + + service.startPeriodicSponsoredStoriesRefresh() + + assertNotNull(GlobalDependencyProvider.SponsoredStories.useCases) + verify(service.spocsRefreshscheduler).stopProfileDeletion(any()) + verify(service.spocsRefreshscheduler).schedulePeriodicRefreshes(any()) + } + + @Test + fun `GIVEN PocketStoriesService is initialized with an invalid profile WHEN called to start periodic refreshes THEN don't schedule periodic refreshes and don't persist dependencies`() { + val service = PocketStoriesService( + context = testContext, + pocketStoriesConfig = PocketStoriesConfig( + client = mock(), + profile = null, + ), + ).apply { + spocsRefreshscheduler = mock() + } + + service.startPeriodicSponsoredStoriesRefresh() + + verify(service.spocsRefreshscheduler, never()).schedulePeriodicRefreshes(any()) + assertNull(GlobalDependencyProvider.SponsoredStories.useCases) + } + + @Test + fun `GIVEN PocketStoriesService WHEN called to stop periodic refreshes THEN stop refreshing stories`() { + // Mock periodic refreshes were started previously and profile details were set. + // Now they will have to be cleaned. + GlobalDependencyProvider.SponsoredStories.initialize(mock()) + service.spocsRefreshscheduler = mock() + + service.stopPeriodicSponsoredStoriesRefresh() + + verify(service.spocsRefreshscheduler).stopPeriodicRefreshes(any()) + } + + @Test + fun `WHEN called to refresh locally saved sponsored stories THEN refresh usecase is invoked`() = runTest { + val refreshStories: SpocsUseCases.RefreshSponsoredStories = mock() + doReturn(refreshStories).`when`(spocsUseCases).refreshStories + + service.refreshSponsoredStories() + + verify(refreshStories).invoke() + } + + @Test + fun `GIVEN PocketStoriesService WHEN getStories THEN stories useCases should return`() = runTest { + val stories = listOf(mock<PocketRecommendedStory>()) + val getStoriesUseCase: GetPocketStories = mock() + doReturn(stories).`when`(getStoriesUseCase).invoke() + doReturn(getStoriesUseCase).`when`(storiesUseCases).getStories + + val result = service.getStories() + + assertEquals(stories, result) + } + + @Test + fun `GIVEN PocketStoriesService WHEN updateStoriesTimesShown THEN delegate to spocs useCases`() = runTest { + val updateTimesShownUseCase: UpdateStoriesTimesShown = mock() + doReturn(updateTimesShownUseCase).`when`(storiesUseCases).updateTimesShown + val stories = listOf(mock<PocketRecommendedStory>()) + + service.updateStoriesTimesShown(stories) + + verify(updateTimesShownUseCase).invoke(stories) + } + + @Test + fun `GIVEN PocketStoriesService WHEN getSponsoredStories THEN delegate to spocs useCases`() = runTest { + val noProfileResponse = service.getSponsoredStories() + assertTrue(noProfileResponse.isEmpty()) + + val stories = listOf(mock<PocketSponsoredStory>()) + val getStoriesUseCase: GetSponsoredStories = mock() + doReturn(stories).`when`(getStoriesUseCase).invoke() + doReturn(getStoriesUseCase).`when`(spocsUseCases).getStories + val existingProfileResponse = service.getSponsoredStories() + assertEquals(stories, existingProfileResponse) + } + + @Test + fun `GIVEN PocketStoriesService is initialized with a valid profile WHEN called to delete profile THEN persist dependencies, cancel stories refresh and schedule profile deletion`() { + val client: Client = mock() + val profileId = UUID.randomUUID() + val appId = "test" + val service = PocketStoriesService( + context = testContext, + pocketStoriesConfig = PocketStoriesConfig( + client = client, + profile = Profile( + profileId = profileId, + appId = appId, + ), + ), + ).apply { + spocsRefreshscheduler = mock() + } + + service.deleteProfile() + + assertNotNull(GlobalDependencyProvider.SponsoredStories.useCases) + verify(service.spocsRefreshscheduler).stopPeriodicRefreshes(any()) + verify(service.spocsRefreshscheduler).scheduleProfileDeletion(any()) + } + + @Test + fun `GIVEN PocketStoriesService is initialized with an invalid profile WHEN called to delete profile THEN don't schedule profile deletion and don't persist dependencies`() { + val service = PocketStoriesService( + context = testContext, + pocketStoriesConfig = PocketStoriesConfig( + client = mock(), + profile = null, + ), + ).apply { + spocsRefreshscheduler = mock() + } + + service.deleteProfile() + + verify(service.spocsRefreshscheduler, never()).scheduleProfileDeletion(any()) + assertNull(GlobalDependencyProvider.SponsoredStories.useCases) + } + + @Test + fun `GIVEN PocketStoriesService WHEN recordStoriesImpressions THEN delegate to spocs useCases`() = runTest { + val recordImpressionsUseCase: RecordImpression = mock() + doReturn(recordImpressionsUseCase).`when`(spocsUseCases).recordImpression + val storiesIds = listOf(22, 33) + + service.recordStoriesImpressions(storiesIds) + + verify(recordImpressionsUseCase).invoke(storiesIds) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/PocketStoryTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/PocketStoryTest.kt new file mode 100644 index 0000000000..36a559b710 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/PocketStoryTest.kt @@ -0,0 +1,100 @@ +/* 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.pocket + +import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory +import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory +import mozilla.components.service.pocket.helpers.assertConstructorsVisibility +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.reflect.KVisibility + +class PocketStoryTest { + @Test + fun `GIVEN PocketSponsoredStory THEN it should be publicly available`() { + assertConstructorsVisibility(PocketSponsoredStory::class, KVisibility.PUBLIC) + } + + @Test + fun `GIVEN PocketSponsoredStoryCaps THEN it should be publicly available`() { + assertConstructorsVisibility(PocketRecommendedStory::class, KVisibility.PUBLIC) + } + + @Test + fun `GIVEN PocketRecommendedStory THEN it should be publicly available`() { + assertConstructorsVisibility(PocketRecommendedStory::class, KVisibility.PUBLIC) + } + + @Test + fun `GIVEN a PocketRecommendedStory WHEN it's title is accessed from parent THEN it returns the previously set value`() { + val pocketRecommendedStory = PocketRecommendedStory( + title = "testTitle", + url = "", + imageUrl = "", + publisher = "", + category = "", + timeToRead = 0, + timesShown = 0, + ) + + val result = (pocketRecommendedStory as PocketStory).title + + assertEquals("testTitle", result) + } + + @Test + fun `GIVEN a PocketRecommendedStory WHEN it's url is accessed from parent THEN it returns the previously set value`() { + val pocketRecommendedStory = PocketRecommendedStory( + title = "", + url = "testUrl", + imageUrl = "", + publisher = "", + category = "", + timeToRead = 0, + timesShown = 0, + ) + + val result = (pocketRecommendedStory as PocketStory).url + + assertEquals("testUrl", result) + } + + @Test + fun `GIVEN a PocketSponsoredStory WHEN it's title is accessed from parent THEN it returns the previously set value`() { + val pocketRecommendedStory = PocketSponsoredStory( + id = 1, + title = "testTitle", + url = "", + imageUrl = "", + sponsor = "", + shim = mock(), + priority = 11, + caps = mock(), + ) + + val result = (pocketRecommendedStory as PocketStory).title + + assertEquals("testTitle", result) + } + + @Test + fun `GIVEN a PocketSponsoredStory WHEN it's url is accessed from parent THEN it returns the previously set value`() { + val pocketRecommendedStory = PocketSponsoredStory( + id = 2, + title = "", + url = "testUrl", + imageUrl = "", + sponsor = "", + shim = mock(), + priority = 33, + caps = mock(), + ) + + val result = (pocketRecommendedStory as PocketStory).url + + assertEquals("testUrl", result) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/ext/ConceptFetchKtTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/ext/ConceptFetchKtTest.kt new file mode 100644 index 0000000000..0155e57ded --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/ext/ConceptFetchKtTest.kt @@ -0,0 +1,80 @@ +/* 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.pocket.ext + +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.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import java.io.IOException + +private const val EXPECTED_DEFAULT_RESPONSE_BODY = "default response body" +private const val TEST_URL = "https://mozilla.org" + +class ConceptFetchKtTest { + + private lateinit var client: Client + private lateinit var defaultResponse: Response + private lateinit var failureResponse: Response + private lateinit var testRequest: Request + + @Before + fun setUp() { + val responseBody = Response.Body(EXPECTED_DEFAULT_RESPONSE_BODY.byteInputStream()) + val failureResponseBody = Response.Body("failure response body)".byteInputStream()) + defaultResponse = spy(Response(TEST_URL, 200, MutableHeaders(), responseBody)) + failureResponse = spy(Response(TEST_URL, 404, MutableHeaders(), failureResponseBody)) + testRequest = Request(TEST_URL, conservative = true) + + client = mock<Client>().also { + whenever(it.fetch(any())).thenReturn(defaultResponse) + } + } + + @Test + fun `GIVEN fetch throws an exception WHEN fetchBodyOrNull is called THEN null is returned`() { + whenever(client.fetch(any())).thenThrow(IOException()) + assertNull(client.fetchBodyOrNull(testRequest)) + } + + @Test + fun `GIVEN fetch returns a failure response WHEN fetchBodyOrNull is called THEN null is returned`() { + setUpClientFailureResponse() + assertNull(client.fetchBodyOrNull(testRequest)) + } + + @Test + fun `GIVEN fetch returns a success response WHEN fetchBodyOrNull is called THEN the response body is returned`() { + val actual = client.fetchBodyOrNull(testRequest) + assertEquals(EXPECTED_DEFAULT_RESPONSE_BODY, actual) + } + + @Test + fun `GIVEN fetch returns a success response WHEN fetchBodyOrNull is called THEN the response is closed`() { + client.fetchBodyOrNull(testRequest) + verify(defaultResponse, times(1)).close() + } + + @Test + fun `GIVEN fetch returns a failure response WHEN fetchBodyOrNull is called THEN the response is closed`() { + setUpClientFailureResponse() + client.fetchBodyOrNull(testRequest) + verify(failureResponse, times(1)).close() + } + + private fun setUpClientFailureResponse() { + whenever(client.fetch(any())).thenReturn(failureResponse) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/ext/MappersKtTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/ext/MappersKtTest.kt new file mode 100644 index 0000000000..bdf6a7bbe6 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/ext/MappersKtTest.kt @@ -0,0 +1,114 @@ +/* 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.pocket.ext + +import mozilla.components.service.pocket.helpers.PocketTestResources +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.reflect.full.memberProperties + +class MappersKtTest { + @Test + fun `GIVEN a PocketApiStory WHEN toPocketLocalStory is called THEN a one to one mapping is performed and timesShown is set to 0`() { + val apiStory = PocketTestResources.apiExpectedPocketStoriesRecommendations[0] + + val result = apiStory.toPocketLocalStory() + + assertNotEquals(apiStory::class.memberProperties, result::class.memberProperties) + assertSame(apiStory.url, result.url) + assertSame(apiStory.title, result.title) + assertSame(apiStory.imageUrl, result.imageUrl) + assertSame(apiStory.publisher, result.publisher) + assertSame(apiStory.category, result.category) + assertSame(apiStory.timeToRead, result.timeToRead) + assertEquals(DEFAULT_TIMES_SHOWN, result.timesShown) + } + + @Test + fun `GIVEN a PocketLocalStory WHEN toPocketRecommendedStory is called THEN a one to one mapping is performed`() { + val localStory = PocketTestResources.dbExpectedPocketStory + + val result = localStory.toPocketRecommendedStory() + + assertNotEquals(localStory::class.memberProperties, result::class.memberProperties) + assertSame(localStory.url, result.url) + assertSame(localStory.title, result.title) + assertSame(localStory.imageUrl, result.imageUrl) + assertSame(localStory.publisher, result.publisher) + assertSame(localStory.category, result.category) + assertSame(localStory.timeToRead, result.timeToRead) + assertEquals(localStory.timesShown, result.timesShown) + } + + @Test + fun `GIVEN a PocketLocalStory with no category WHEN toPocketRecommendedStory is called THEN a one to one mapping is performed and the category is set to general`() { + val localStory = PocketTestResources.dbExpectedPocketStory.copy(category = "") + + val result = localStory.toPocketRecommendedStory() + + assertNotEquals(localStory::class.memberProperties, result::class.memberProperties) + assertSame(localStory.url, result.url) + assertSame(localStory.title, result.title) + assertSame(localStory.imageUrl, result.imageUrl) + assertSame(localStory.publisher, result.publisher) + assertSame(DEFAULT_CATEGORY, result.category) + assertSame(localStory.timeToRead, result.timeToRead) + assertEquals(localStory.timesShown, result.timesShown) + } + + @Test + fun `GIVEN a PcoketRecommendedStory WHEN toPartialTimeShownUpdate is called THEN only the url and timesShown properties are kept`() { + val story = PocketTestResources.clientExpectedPocketStory + + val result = story.toPartialTimeShownUpdate() + + assertNotEquals(story::class.memberProperties, result::class.memberProperties) + assertEquals(2, result::class.memberProperties.size) + assertSame(story.url, result.url) + assertSame(story.timesShown, result.timesShown) + } + + @Test + fun `GIVEN a spoc downloaded from Internet WHEN it is converted to a local spoc THEN a one to one mapping is made`() { + val apiStory = PocketTestResources.apiExpectedPocketSpocs[0] + + val result = apiStory.toLocalSpoc() + + assertEquals(apiStory.id, result.id) + assertSame(apiStory.title, result.title) + assertSame(apiStory.url, result.url) + assertSame(apiStory.imageSrc, result.imageUrl) + assertSame(apiStory.sponsor, result.sponsor) + assertSame(apiStory.shim.click, result.clickShim) + assertSame(apiStory.shim.impression, result.impressionShim) + assertEquals(apiStory.priority, result.priority) + assertEquals(apiStory.caps.lifetimeCount, result.lifetimeCapCount) + assertEquals(apiStory.caps.flightCount, result.flightCapCount) + assertEquals(apiStory.caps.flightPeriod, result.flightCapPeriod) + } + + @Test + fun `GIVEN a local spoc WHEN it is converted to be exposed to clients THEN a one to one mapping is made`() { + val localStory = PocketTestResources.dbExpectedPocketSpoc + + val result = localStory.toPocketSponsoredStory() + + assertEquals(localStory.id, result.id) + assertSame(localStory.title, result.title) + assertSame(localStory.url, result.url) + assertSame(localStory.imageUrl, result.imageUrl) + assertSame(localStory.sponsor, result.sponsor) + assertSame(localStory.clickShim, result.shim.click) + assertSame(localStory.impressionShim, result.shim.impression) + assertEquals(localStory.priority, result.priority) + assertEquals(localStory.lifetimeCapCount, result.caps.lifetimeCount) + assertEquals(localStory.flightCapCount, result.caps.flightCount) + assertEquals(localStory.flightCapPeriod, result.caps.flightPeriod) + assertTrue(result.caps.currentImpressions.isEmpty()) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/ext/PocketStoryKtTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/ext/PocketStoryKtTest.kt new file mode 100644 index 0000000000..6fea0accbf --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/ext/PocketStoryKtTest.kt @@ -0,0 +1,135 @@ +/* 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.pocket.ext + +import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory +import mozilla.components.service.pocket.PocketStory.PocketSponsoredStoryCaps +import mozilla.components.service.pocket.helpers.PocketTestResources +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.doReturn + +class PocketStoryKtTest { + private val nowInSeconds = System.currentTimeMillis() / 1000 + private val flightPeriod = 100 + private val flightImpression1 = nowInSeconds - flightPeriod / 2 + private val flightImpression2 = nowInSeconds - flightPeriod / 3 + private val currentImpressions = listOf( + nowInSeconds - flightPeriod * 2, // older impression that doesn't fit the flight period + flightImpression1, + flightImpression2, + ) + + @Test + fun `GIVEN sponsored story impressions recorded WHEN asking for the current flight impression THEN return all impressions in flight period`() { + val storyCaps = PocketSponsoredStoryCaps( + currentImpressions = currentImpressions, + lifetimeCount = 10, + flightCount = 5, + flightPeriod = flightPeriod, + ) + val story: PocketSponsoredStory = mock() + doReturn(storyCaps).`when`(story).caps + + val result = story.getCurrentFlightImpressions() + + assertEquals(listOf(flightImpression1, flightImpression2), result) + } + + @Test + fun `GIVEN sponsored story impressions recorded WHEN asking if lifetime impressions reached THEN return false if not`() { + val storyCaps = PocketSponsoredStoryCaps( + currentImpressions = currentImpressions, + lifetimeCount = 10, + flightCount = 5, + flightPeriod = flightPeriod, + ) + val story: PocketSponsoredStory = mock() + doReturn(storyCaps).`when`(story).caps + + val result = story.hasLifetimeImpressionsLimitReached() + + assertFalse(result) + } + + @Test + fun `GIVEN sponsored story impressions recorded WHEN asking if lifetime impressions reached THEN return true if so`() { + val storyCaps = PocketSponsoredStoryCaps( + currentImpressions = currentImpressions, + lifetimeCount = 3, + flightCount = 3, + flightPeriod = flightPeriod, + ) + val story: PocketSponsoredStory = mock() + doReturn(storyCaps).`when`(story).caps + + val result = story.hasLifetimeImpressionsLimitReached() + + assertTrue(result) + } + + @Test + fun `GIVEN sponsored story impressions recorded WHEN asking if flight impressions reached THEN return false if not`() { + val storyCaps = PocketSponsoredStoryCaps( + currentImpressions = currentImpressions, + lifetimeCount = 10, + flightCount = 5, + flightPeriod = flightPeriod, + ) + val story: PocketSponsoredStory = mock() + doReturn(storyCaps).`when`(story).caps + + val result = story.hasFlightImpressionsLimitReached() + + assertFalse(result) + } + + @Test + fun `GIVEN sponsored story impressions recorded WHEN asking if flight impressions reached THEN return true if so`() { + val storyCaps = PocketSponsoredStoryCaps( + currentImpressions = currentImpressions, + lifetimeCount = 3, + flightCount = 2, + flightPeriod = flightPeriod, + ) + val story: PocketSponsoredStory = mock() + doReturn(storyCaps).`when`(story).caps + + val result = story.hasFlightImpressionsLimitReached() + + assertTrue(result) + } + + @Test + fun `GIVEN a sponsored story WHEN recording a new impression THEN update the same story to contain a new impression recorded in seconds`() { + val story = PocketTestResources.dbExpectedPocketSpoc.toPocketSponsoredStory(currentImpressions) + + assertEquals(3, story.caps.currentImpressions.size) + val result = story.recordNewImpression() + + assertEquals(story.id, result.id) + assertSame(story.title, result.title) + assertSame(story.url, result.url) + assertSame(story.imageUrl, result.imageUrl) + assertSame(story.sponsor, result.sponsor) + assertSame(story.shim, result.shim) + assertEquals(story.priority, result.priority) + assertEquals(story.caps.lifetimeCount, result.caps.lifetimeCount) + assertEquals(story.caps.flightCount, result.caps.flightCount) + assertEquals(story.caps.flightPeriod, result.caps.flightPeriod) + + assertEquals(4, result.caps.currentImpressions.size) + assertEquals(currentImpressions, result.caps.currentImpressions.take(3)) + // Check if a new impression has been added for around this current time. + assertTrue( + LongRange(nowInSeconds - 5, nowInSeconds + 5) + .contains(result.caps.currentImpressions[3]), + ) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/helpers/Assert.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/helpers/Assert.kt new file mode 100644 index 0000000000..1219dc139e --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/helpers/Assert.kt @@ -0,0 +1,83 @@ +/* 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.pocket.helpers + +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.service.pocket.stories.api.PocketResponse +import mozilla.components.support.test.any +import mozilla.components.support.test.whenever +import org.junit.Assert.assertEquals +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import kotlin.reflect.KClass +import kotlin.reflect.KVisibility + +fun <T : Any> assertConstructorsVisibility(assertedClass: KClass<T>, visibility: KVisibility) { + assertedClass.constructors.forEach { + assertEquals(visibility, it.visibility) + } +} + +fun <T : Any> assertClassVisibility(assertedClass: KClass<T>, visibility: KVisibility) { + assertEquals(visibility, assertedClass.visibility) +} + +/** + * @param client the underlying mock client for the raw endpoint making the request. + * @param makeRequest makes the request using the raw endpoint. + * @param assertParams makes assertions on the passed in request. + */ +fun assertRequestParams(client: Client, makeRequest: () -> Unit, assertParams: (Request) -> Unit) { + whenever(client.fetch(any())).thenAnswer { + val request = it.arguments[0] as Request + assertParams(request) + Response("https://mozilla.org", 200, MutableHeaders(), Response.Body("".byteInputStream())) + } + + makeRequest() + + // Ensure fetch is called so that the assertions in assertParams are called. + verify(client, times(1)).fetch(any()) +} + +/** + * @param client the underlying mock client for the raw endpoint making the request. + * @param makeRequest makes the request using the raw endpoint and returns the body text, or null on error + */ +fun assertSuccessfulRequestReturnsResponseBody(client: Client, makeRequest: () -> String?) { + val expectedBody = "{\"jsonStr\": true}" + val body = mock(Response.Body::class.java).also { + whenever(it.string()).thenReturn(expectedBody) + } + val response = MockResponses.getSuccess().also { + whenever(it.body).thenReturn(body) + } + whenever(client.fetch(any())).thenReturn(response) + + assertEquals(expectedBody, makeRequest()) +} + +/** + * @param client the underlying mock client for the raw endpoint making the request. + * @param response the response to return when the request is made. + * @param makeRequest makes the request using the raw endpoint. + */ +fun assertResponseIsClosed(client: Client, response: Response, makeRequest: () -> Unit) { + whenever(client.fetch(any())).thenReturn(response) + makeRequest() + verify(response, times(1)).close() +} + +fun assertResponseIsFailure(response: Any) { + assertEquals(PocketResponse.Failure::class.java, response.javaClass) +} + +fun assertResponseIsSuccess(response: Any) { + assertEquals(PocketResponse.Success::class.java, response.javaClass) +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/helpers/MockResponses.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/helpers/MockResponses.kt new file mode 100644 index 0000000000..d0742d49f4 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/helpers/MockResponses.kt @@ -0,0 +1,30 @@ +/* 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.pocket.helpers + +import mozilla.components.concept.fetch.Response +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.mockito.Mockito.mock + +/** + * A collection of helper functions to generate mock [Response]s. + */ +object MockResponses { + + fun getError(): Response = getMockResponse(404) + + fun getSuccess(): Response = getMockResponse(200).also { + // A successful response must contain a body. + val body = mock(Response.Body::class.java).also { body -> + whenever(body.string()).thenReturn("{}") + } + whenever(it.body).thenReturn(body) + } + + private fun getMockResponse(status: Int): Response = mock<Response>().also { + whenever(it.status).thenReturn(status) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/helpers/PocketTestResources.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/helpers/PocketTestResources.kt new file mode 100644 index 0000000000..aaa0cbc716 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/helpers/PocketTestResources.kt @@ -0,0 +1,171 @@ +/* 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.pocket.helpers + +import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory +import mozilla.components.service.pocket.spocs.api.ApiSpoc +import mozilla.components.service.pocket.spocs.api.ApiSpocCaps +import mozilla.components.service.pocket.spocs.api.ApiSpocShim +import mozilla.components.service.pocket.spocs.db.SpocEntity +import mozilla.components.service.pocket.stories.api.PocketApiStory +import mozilla.components.service.pocket.stories.db.PocketStoryEntity + +private const val POCKET_DIR = "pocket" + +/** + * Accessors to resources used in testing. + */ +internal object PocketTestResources { + val pocketEndpointFiveStoriesResponse = this::class.java.classLoader!!.getResource( + "$POCKET_DIR/stories_recommendations_response.json", + )!!.readText() + + val pocketEndpointThreeSpocsResponse = this::class.java.classLoader!!.getResource( + "$POCKET_DIR/sponsored_stories_response.json", + )!!.readText() + + val pocketEndpointNullTitleStoryBadResponse = this::class.java.classLoader!!.getResource( + "$POCKET_DIR/story_recommendation_null_title_response.json", + )!!.readText() + + val pocketEndpointNullUrlStoryBadResponse = this::class.java.classLoader!!.getResource( + "$POCKET_DIR/story_recommendation_null_url_response.json", + )!!.readText() + + val pocketEndpointNullImageUrlStoryBadResponse = this::class.java.classLoader!!.getResource( + "$POCKET_DIR/story_recommendation_null_imageUrl_response.json", + )!!.readText() + + val apiExpectedPocketStoriesRecommendations: List<PocketApiStory> = listOf( + PocketApiStory( + title = "How to Remember Anything You Really Want to Remember, Backed by Science", + url = "https://getpocket.com/explore/item/how-to-remember-anything-you-really-want-to-remember-backed-by-science", + imageUrl = "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpocket-image-cache.com%2F1200x%2Ffilters%3Aformat(jpg)%3Aextract_focal()%2Fhttps%253A%252F%252Fwww.incimages.com%252Fuploaded_files%252Fimage%252F1920x1080%252Fgetty-862457080_394628.jpg", + publisher = "Pocket", + category = "general", + timeToRead = 3, + ), + PocketApiStory( + title = "‘I Don’t Want to Be Like a Family With My Co-Workers’", + url = "https://www.thecut.com/article/i-dont-want-to-be-like-a-family-with-my-co-workers.html", + imageUrl = "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpyxis.nymag.com%2Fv1%2Fimgs%2Fac8%2Fd22%2F315cd0cf1e3a43edfe0e0548f2edbcb1a1-ask-a-boss.1x.rsocial.w1200.jpg", + publisher = "The Cut", + category = "general", + timeToRead = 5, + ), + PocketApiStory( + title = "How America Failed in Afghanistan", + url = "https://www.newyorker.com/news/q-and-a/how-america-failed-in-afghanistan", + imageUrl = "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fmedia.newyorker.com%2Fphotos%2F6119484157b611aec9c99b43%2F16%3A9%2Fw_1280%2Cc_limit%2FChotiner-Afghanistan01.jpg", + publisher = "The New Yorker", + category = "general", + timeToRead = 14, + ), + PocketApiStory( + title = "How digital beauty filters perpetuate colorism", + url = "https://www.technologyreview.com/2021/08/15/1031804/digital-beauty-filters-photoshop-photo-editing-colorism-racism/", + imageUrl = "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fwp.technologyreview.com%2Fwp-content%2Fuploads%2F2021%2F08%2FBeautyScoreColorism.jpg%3Fresize%3D1200%2C600", + publisher = "MIT Technology Review", + category = "general", + timeToRead = 11, + ), + PocketApiStory( + title = "How to Get Rid of Black Mold Naturally", + url = "https://getpocket.com/explore/item/how-to-get-rid-of-black-mold-naturally", + imageUrl = "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpocket-image-cache.com%2F1200x%2Ffilters%3Aformat(jpg)%3Aextract_focal()%2Fhttps%253A%252F%252Fpocket-syndicated-images.s3.amazonaws.com%252Farticles%252F6757%252F1628024495_6109ae86db6cc.png", + publisher = "Pocket", + category = "general", + timeToRead = 4, + ), + ) + + val apiExpectedPocketSpocs: List<ApiSpoc> = listOf( + ApiSpoc( + id = 193815086, + title = "Eating Keto Has Never Been So Easy With Green Chef", + url = "https://i.geistm.com/l/GC_7ReasonsKetoV2_Journiest?bcid=601c567ac5b18a0414cce1d4&bhid=624f3ea9adad7604086ac6b3&utm_content=PKT_A_7ReasonsKetoV2_Journiest_40702022_RawMeatballUGC_130Off_601c567ac5b18a0414cce1d4_624f3ea9adad7604086ac6b3&tv=su4&ct=NAT-PK-PROS-130OFF5WEEK-037&utm_medium=DB&utm_source=pocket~geistm&utm_campaign=PKT_A_7ReasonsKetoV2_Journiest_40702022_RawMeatballUGC_130Off", + imageSrc = "https://img-getpocket.cdn.mozilla.net/direct?url=realUrl.png&resize=w618-h310", + sponsor = "Green Chef", + shim = ApiSpocShim( + click = "193815086ClickShim", + impression = "193815086ImpressionShim", + ), + priority = 3, + caps = ApiSpocCaps( + lifetimeCount = 50, + flightPeriod = 86400, + flightCount = 10, + ), + ), + ApiSpoc( + id = 177986195, + title = "This Leading Cash Back Card Is a Slam Dunk if You Want a One-Card Wallet", + url = "https://www.fool.com/the-ascent/credit-cards/landing/discover-it-cash-back-review-v2-csr/?utm_site=theascent&utm_campaign=ta-cc-co-pocket-discb-04012022-5-na-firefox&utm_medium=cpc&utm_source=pocket", + imageSrc = "https://img-getpocket.cdn.mozilla.net/direct?url=https%3A//s.zkcdn.net/Advertisers/359f56a5423c4926ab3aa148e448d839.webp&resize=w618-h310", + sponsor = "The Ascent", + shim = ApiSpocShim( + click = "177986195ClickShim", + impression = "177986195ImpressionShim", + ), + priority = 2, + caps = ApiSpocCaps( + lifetimeCount = 50, + flightPeriod = 86400, + flightCount = 10, + ), + ), + ApiSpoc( + id = 192560056, + title = "The Incredible Lawn Hack That Can Make Your Neighbors Green With Envy Over Your Lawn", + url = "https://go.lawnbuddy.org/zf/50/7673?campaign=SUN_Pocket2022&creative=SUN_LawnCompare4-TheIncredibleLawnHackThatCanMakeYourNeighborsGreenWithEnvyOverYourLawn-WithoutSpendingAFortuneOnNewGrassAndWithoutBreakingASweat-20220420", + imageSrc = "https://img-getpocket.cdn.mozilla.net/direct?url=https%3A//s.zkcdn.net/Advertisers/ce16302e184342cda0619c08b7604c9c.jpg&resize=w618-h310", + sponsor = "Sunday", + shim = ApiSpocShim( + click = "192560056ClickShim", + impression = "192560056ImpressionShim", + ), + priority = 1, + caps = ApiSpocCaps( + lifetimeCount = 50, + flightPeriod = 86400, + flightCount = 10, + ), + ), + ) + + val dbExpectedPocketStory = PocketStoryEntity( + title = "How to Get Rid of Black Mold Naturally", + url = "https://getpocket.com/explore/item/how-to-get-rid-of-black-mold-naturally", + imageUrl = "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpocket-image-cache.com%2F1200x%2Ffilters%3Aformat(jpg)%3Aextract_focal()%2Fhttps%253A%252F%252Fpocket-syndicated-images.s3.amazonaws.com%252Farticles%252F6757%252F1628024495_6109ae86db6cc.png", + publisher = "Pocket", + category = "general", + timeToRead = 4, + timesShown = 23, + ) + + val clientExpectedPocketStory = PocketRecommendedStory( + title = "How digital beauty filters perpetuate colorism", + url = "https://www.technologyreview.com/2021/08/15/1031804/digital-beauty-filters-photoshop-photo-editing-colorism-racism/", + imageUrl = "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fwp.technologyreview.com%2Fwp-content%2Fuploads%2F2021%2F08%2FBeautyScoreColorism.jpg%3Fresize%3D1200%2C600", + publisher = "MIT Technology Review", + category = "general", + timeToRead = 11, + timesShown = 3, + ) + + val dbExpectedPocketSpoc = SpocEntity( + id = 193815086, + url = "https://i.geistm.com/l/GC_7ReasonsKetoV2_Journiest?bcid=601c567ac5b18a0414cce1d4&bhid=624f3ea9adad7604086ac6b3&utm_content=PKT_A_7ReasonsKetoV2_Journiest_40702022_RawMeatballUGC_130Off_601c567ac5b18a0414cce1d4_624f3ea9adad7604086ac6b3&tv=su4&ct=NAT-PK-PROS-130OFF5WEEK-037&utm_medium=DB&utm_source=pocket~geistm&utm_campaign=PKT_A_7ReasonsKetoV2_Journiest_40702022_RawMeatballUGC_130Off", + title = "Eating Keto Has Never Been So Easy With Green Chef", + imageUrl = "https://img-getpocket.cdn.mozilla.net/direct?url=realUrl.png&resize=w618-h310", + sponsor = "Green Chef", + clickShim = "193815086ClickShim", + impressionShim = "193815086ImpressionShim", + priority = 3, + lifetimeCapCount = 50, + flightCapCount = 10, + flightCapPeriod = 86400, + ) +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/SpocsRepositoryTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/SpocsRepositoryTest.kt new file mode 100644 index 0000000000..8e3dda6f02 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/SpocsRepositoryTest.kt @@ -0,0 +1,93 @@ +/* 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.pocket.spocs + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.service.pocket.ext.toLocalSpoc +import mozilla.components.service.pocket.helpers.PocketTestResources +import mozilla.components.service.pocket.spocs.db.SpocImpressionEntity +import mozilla.components.service.pocket.spocs.db.SpocsDao +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify + +@ExperimentalCoroutinesApi // for runTest +@RunWith(AndroidJUnit4::class) +class SpocsRepositoryTest { + private val spocsRepo = spy(SpocsRepository(testContext)) + private val dao = mock(SpocsDao::class.java) + + @Before + fun setUp() { + doReturn(dao).`when`(spocsRepo).spocsDao + } + + @Test + fun `GIVEN SpocsRepository WHEN asking for all spocs THEN return db entities mapped to domain type`() = runTest { + val spoc = PocketTestResources.dbExpectedPocketSpoc + val impressions = listOf( + SpocImpressionEntity(spoc.id), + SpocImpressionEntity(333), + SpocImpressionEntity(spoc.id), + ) + doReturn(listOf(spoc)).`when`(dao).getAllSpocs() + doReturn(impressions).`when`(dao).getSpocsImpressions() + + val result = spocsRepo.getAllSpocs() + + verify(dao).getAllSpocs() + assertEquals(1, result.size) + assertSame(spoc.title, result[0].title) + assertSame(spoc.url, result[0].url) + assertSame(spoc.imageUrl, result[0].imageUrl) + assertSame(spoc.impressionShim, result[0].shim.impression) + assertSame(spoc.clickShim, result[0].shim.click) + assertEquals(spoc.priority, result[0].priority) + assertEquals(2, result[0].caps.currentImpressions.size) + assertEquals(spoc.lifetimeCapCount, result[0].caps.lifetimeCount) + assertEquals(spoc.flightCapCount, result[0].caps.flightCount) + assertEquals(spoc.flightCapPeriod, result[0].caps.flightPeriod) + } + + @Test + fun `GIVEN SpocsRepository WHEN asking to delete all spocs THEN delete all from the database`() = runTest { + spocsRepo.deleteAllSpocs() + + verify(dao).deleteAllSpocs() + } + + @Test + fun `GIVEN SpocsRepository WHEN adding a new list of spocs THEN replace all present in the database`() = runTest { + val spoc = PocketTestResources.apiExpectedPocketSpocs[0] + + spocsRepo.addSpocs(listOf(spoc)) + + verify(dao).cleanOldAndInsertNewSpocs(listOf(spoc.toLocalSpoc())) + } + + @Test + fun `GIVEN SpocsRepository WHEN recording new spocs impressions THEN add this to the database`() = runTest { + val spocsIds = listOf(3, 33, 444) + val impressionsCaptor = argumentCaptor<List<SpocImpressionEntity>>() + + spocsRepo.recordImpressions(spocsIds) + + verify(dao).recordImpressions(impressionsCaptor.capture()) + assertEquals(spocsIds.size, impressionsCaptor.value.size) + assertEquals(spocsIds[0], impressionsCaptor.value[0].spocId) + assertEquals(spocsIds[1], impressionsCaptor.value[1].spocId) + assertEquals(spocsIds[2], impressionsCaptor.value[2].spocId) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/SpocsUseCasesTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/SpocsUseCasesTest.kt new file mode 100644 index 0000000000..1ce56903cc --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/SpocsUseCasesTest.kt @@ -0,0 +1,314 @@ +/* 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.pocket.spocs + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.concept.fetch.Client +import mozilla.components.service.pocket.PocketStoriesRequestConfig +import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory +import mozilla.components.service.pocket.helpers.PocketTestResources +import mozilla.components.service.pocket.helpers.assertClassVisibility +import mozilla.components.service.pocket.spocs.SpocsUseCases.RefreshSponsoredStories +import mozilla.components.service.pocket.spocs.api.SpocsEndpoint +import mozilla.components.service.pocket.stories.api.PocketResponse +import mozilla.components.service.pocket.stories.api.PocketResponse.Failure +import mozilla.components.service.pocket.stories.api.PocketResponse.Success +import mozilla.components.support.test.any +import mozilla.components.support.test.argumentCaptor +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import java.util.UUID +import kotlin.reflect.KVisibility + +@OptIn(ExperimentalCoroutinesApi::class) // for runTest +@RunWith(AndroidJUnit4::class) +class SpocsUseCasesTest { + private val fetchClient: Client = mock() + private val profileId = UUID.randomUUID() + private val appId = "test" + private val sponsoredStoriesParams = PocketStoriesRequestConfig("123", "US", "NY") + private val useCases = spy(SpocsUseCases(testContext, fetchClient, profileId, appId, sponsoredStoriesParams)) + private val spocsProvider: SpocsEndpoint = mock() + private val spocsRepo: SpocsRepository = mock() + + @Before + fun setup() { + doReturn(spocsProvider).`when`(useCases).getSpocsProvider(any(), any(), any(), any()) + doReturn(spocsRepo).`when`(useCases).getSpocsRepository(any()) + } + + @Test + fun `GIVEN a SpocsUseCases THEN its visibility is internal`() { + assertClassVisibility(SpocsUseCases::class, KVisibility.INTERNAL) + } + + @Test + fun `GIVEN a RefreshSponsoredStories THEN its visibility is internal`() { + assertClassVisibility(RefreshSponsoredStories::class, KVisibility.INTERNAL) + } + + @Test + fun `GIVEN a GetSponsoredStories THEN its visibility is internal`() { + assertClassVisibility(SpocsUseCases.GetSponsoredStories::class, KVisibility.INTERNAL) + } + + @Test + fun `GIVEN a DeleteProfile THEN its visibility is internal`() { + assertClassVisibility(SpocsUseCases.DeleteProfile::class, KVisibility.INTERNAL) + } + + @Test + fun `GIVEN SpocsUseCases WHEN RefreshSponsoredStories is constructed THEN use the same parameters`() { + val refreshUseCase = useCases.refreshStories + + assertSame(testContext, refreshUseCase.appContext) + assertSame(fetchClient, refreshUseCase.fetchClient) + assertSame(profileId, refreshUseCase.profileId) + assertSame(appId, refreshUseCase.appId) + assertSame(sponsoredStoriesParams, refreshUseCase.sponsoredStoriesParams) + } + + @Test + fun `GIVEN SpocsUseCases constructed WHEN RefreshSponsoredStories is constructed separately THEN default to use the same parameters`() { + val refreshUseCase = useCases.RefreshSponsoredStories() + + assertSame(testContext, refreshUseCase.appContext) + assertSame(fetchClient, refreshUseCase.fetchClient) + assertSame(profileId, refreshUseCase.profileId) + assertSame(appId, refreshUseCase.appId) + assertSame(sponsoredStoriesParams, refreshUseCase.sponsoredStoriesParams) + } + + @Test + fun `GIVEN SpocsUseCases constructed WHEN RefreshSponsoredStories is constructed separately THEN allow using different parameters`() { + val context2: Context = mock() + val fetchClient2: Client = mock() + val profileId2 = UUID.randomUUID() + val appId2 = "test" + val sponsoredStoriesParams2 = PocketStoriesRequestConfig("1", "CA", "OW") + + val refreshUseCase = useCases.RefreshSponsoredStories(context2, fetchClient2, profileId2, appId2, sponsoredStoriesParams2) + + assertSame(context2, refreshUseCase.appContext) + assertSame(fetchClient2, refreshUseCase.fetchClient) + assertSame(profileId2, refreshUseCase.profileId) + assertSame(appId2, refreshUseCase.appId) + assertSame(sponsoredStoriesParams2, refreshUseCase.sponsoredStoriesParams) + } + + @Test + fun `GIVEN SpocsUseCases WHEN RefreshSponsoredStories is called THEN download stories from API and return early if unsuccessful response`() = runTest { + val refreshUseCase = useCases.RefreshSponsoredStories() + val unsuccessfulResponse = getFailedSponsoredStories() + doReturn(unsuccessfulResponse).`when`(spocsProvider).getSponsoredStories() + + val result = refreshUseCase.invoke() + + assertFalse(result) + verify(spocsProvider).getSponsoredStories() + verify(spocsRepo, never()).addSpocs(any()) + } + + @Test + fun `GIVEN SpocsUseCases WHEN RefreshSponsoredStories is called THEN download stories from API and save a successful response locally`() = runTest { + val refreshUseCase = useCases.RefreshSponsoredStories() + val successfulResponse = getSuccessfulSponsoredStories() + doReturn(successfulResponse).`when`(spocsProvider).getSponsoredStories() + + val result = refreshUseCase.invoke() + + assertTrue(result) + verify(spocsProvider).getSponsoredStories() + verify(spocsRepo).addSpocs((successfulResponse as Success).data) + } + + @Test + fun `GIVEN SpocsUseCases WHEN GetSponsoredStories is constructed THEN use the same parameters`() { + val sponsoredStoriesUseCase = useCases.getStories + + assertSame(testContext, sponsoredStoriesUseCase.context) + } + + @Test + fun `GIVEN SpocsUseCases constructed WHEN GetSponsoredStories is constructed separately THEN default to use the same parameters`() { + val sponsoredStoriesUseCase = useCases.GetSponsoredStories() + + assertSame(testContext, sponsoredStoriesUseCase.context) + } + + @Test + fun `GIVEN SpocsUseCases constructed WHEN GetSponsoredStories is constructed separately THEN allow using different parameters`() { + val context2: Context = mock() + + val sponsoredStoriesUseCase = useCases.GetSponsoredStories(context2) + + assertSame(context2, sponsoredStoriesUseCase.context) + } + + @Test + fun `GIVEN SpocsUseCases WHEN GetSponsoredStories is called THEN return the stories from repository`() = runTest { + val sponsoredStoriesUseCase = useCases.GetSponsoredStories() + val stories = listOf(PocketTestResources.clientExpectedPocketStory) + doReturn(stories).`when`(spocsRepo).getAllSpocs() + + val result = sponsoredStoriesUseCase.invoke() + + verify(spocsRepo).getAllSpocs() + assertEquals(result, stories) + } + + @Test + fun `GIVEN SpocsUseCases WHEN GetSponsoredStories is called THEN return return an empty list if none are available in the repository`() = runTest { + val sponsoredStoriesUseCase = useCases.GetSponsoredStories() + doReturn(emptyList<PocketRecommendedStory>()).`when`(spocsRepo).getAllSpocs() + + val result = sponsoredStoriesUseCase.invoke() + + verify(spocsRepo).getAllSpocs() + assertTrue(result.isEmpty()) + } + + @Test + fun `GIVEN SpocsUseCases WHEN RecordImpression is constructed THEN use the same parameters`() { + val recordImpressionsUseCase = useCases.getStories + + assertSame(testContext, recordImpressionsUseCase.context) + } + + @Test + fun `GIVEN SpocsUseCases constructed WHEN RecordImpression is constructed separately THEN default to use the same parameters`() { + val recordImpressionsUseCase = useCases.RecordImpression() + + assertSame(testContext, recordImpressionsUseCase.context) + } + + @Test + fun `GIVEN SpocsUseCases constructed WHEN RecordImpression is constructed separately THEN allow using different parameters`() { + val context2: Context = mock() + + val recordImpressionsUseCase = useCases.RecordImpression(context2) + + assertSame(context2, recordImpressionsUseCase.context) + } + + @Test + fun `GIVEN SpocsUseCases WHEN RecordImpression is called THEN record impressions in database`() = runTest { + val recordImpressionsUseCase = useCases.RecordImpression() + val storiesIds = listOf(5, 55, 4321) + val spocsIdsCaptor = argumentCaptor<List<Int>>() + + recordImpressionsUseCase(storiesIds) + + verify(spocsRepo).recordImpressions(spocsIdsCaptor.capture()) + assertEquals(3, spocsIdsCaptor.value.size) + assertEquals(storiesIds[0], spocsIdsCaptor.value[0]) + assertEquals(storiesIds[1], spocsIdsCaptor.value[1]) + assertEquals(storiesIds[2], spocsIdsCaptor.value[2]) + } + + @Test + fun `GIVEN SpocsUseCases WHEN DeleteProfile is constructed THEN use the same parameters`() { + val deleteProfileUseCase = useCases.deleteProfile + + assertSame(testContext, deleteProfileUseCase.context) + assertSame(fetchClient, deleteProfileUseCase.fetchClient) + assertSame(profileId, deleteProfileUseCase.profileId) + assertSame(appId, deleteProfileUseCase.appId) + assertSame(sponsoredStoriesParams, deleteProfileUseCase.sponsoredStoriesParams) + } + + @Test + fun `GIVEN SpocsUseCases constructed WHEN DeleteProfile is constructed separately THEN default to use the same parameters`() { + val deleteProfileUseCase = useCases.DeleteProfile() + + assertSame(testContext, deleteProfileUseCase.context) + assertSame(fetchClient, deleteProfileUseCase.fetchClient) + assertSame(profileId, deleteProfileUseCase.profileId) + assertSame(appId, deleteProfileUseCase.appId) + assertSame(sponsoredStoriesParams, deleteProfileUseCase.sponsoredStoriesParams) + } + + @Test + fun `GIVEN SpocsUseCases constructed WHEN DeleteProfile is constructed separately THEN allow using different parameters`() { + val context2: Context = mock() + val fetchClient2: Client = mock() + val profileId2 = UUID.randomUUID() + val appId2 = "test" + val sponsoredStoriesParams2 = PocketStoriesRequestConfig("1", "CA", "OW") + + val deleteProfileUseCase = useCases.DeleteProfile(context2, fetchClient2, profileId2, appId2, sponsoredStoriesParams2) + + assertSame(context2, deleteProfileUseCase.context) + assertSame(fetchClient2, deleteProfileUseCase.fetchClient) + assertSame(profileId2, deleteProfileUseCase.profileId) + assertSame(appId2, deleteProfileUseCase.appId) + assertSame(sponsoredStoriesParams2, deleteProfileUseCase.sponsoredStoriesParams) + } + + @Test + fun `GIVEN SpocsUseCases WHEN DeleteProfile is called THEN return true if profile deletion was successful`() = runTest { + val deleteProfileUseCase = useCases.DeleteProfile() + val successfulResponse = Success(true) + doReturn(successfulResponse).`when`(spocsProvider).deleteProfile() + + val result = deleteProfileUseCase.invoke() + + verify(spocsProvider).deleteProfile() + assertTrue(result) + } + + @Test + fun `GIVEN SpocsUseCases WHEN DeleteProfile is called THEN return false if profile deletion was not successful`() = runTest { + val deleteProfileUseCase = useCases.DeleteProfile() + val unsuccessfulResponse = Failure<Any>() + doReturn(unsuccessfulResponse).`when`(spocsProvider).deleteProfile() + + val result = deleteProfileUseCase.invoke() + + verify(spocsProvider).deleteProfile() + assertFalse(result) + } + + @Test + fun `GIVEN SpocsUseCases WHEN profile deletion is succesfull THEN delete all locally persisted spocs`() = runTest { + val deleteProfileUseCase = useCases.DeleteProfile() + val successfulResponse = Success(true) + doReturn(successfulResponse).`when`(spocsProvider).deleteProfile() + + deleteProfileUseCase.invoke() + + verify(spocsRepo).deleteAllSpocs() + } + + @Test + fun `GIVEN SpocsUseCases WHEN profile deletion is not succesfull THEN keep all locally persisted spocs`() = runTest { + val deleteProfileUseCase = useCases.DeleteProfile() + val unsuccessfulResponse = Failure<Any>() + doReturn(unsuccessfulResponse).`when`(spocsProvider).deleteProfile() + + deleteProfileUseCase.invoke() + + verify(spocsRepo, never()).deleteAllSpocs() + } + + private fun getSuccessfulSponsoredStories() = + PocketResponse.wrap(PocketTestResources.apiExpectedPocketSpocs) + + private fun getFailedSponsoredStories() = PocketResponse.wrap(null) +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/api/SpocsEndpointRawTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/api/SpocsEndpointRawTest.kt new file mode 100644 index 0000000000..c2af1d7cb4 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/api/SpocsEndpointRawTest.kt @@ -0,0 +1,327 @@ +/* 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.pocket.spocs.api + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.service.pocket.PocketStoriesRequestConfig +import mozilla.components.service.pocket.helpers.MockResponses +import mozilla.components.service.pocket.helpers.assertClassVisibility +import mozilla.components.service.pocket.helpers.assertRequestParams +import mozilla.components.service.pocket.helpers.assertResponseIsClosed +import mozilla.components.service.pocket.helpers.assertSuccessfulRequestReturnsResponseBody +import mozilla.components.service.pocket.stories.api.PocketEndpointRaw +import mozilla.components.service.pocket.stories.api.PocketEndpointRaw.Companion +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.doThrow +import java.io.IOException +import java.util.UUID +import kotlin.reflect.KVisibility + +@RunWith(AndroidJUnit4::class) +class SpocsEndpointRawTest { + private val profileId = UUID.randomUUID() + private val appId = "test" + private val sponsoredStoriesParams: PocketStoriesRequestConfig = mock() + + private lateinit var endpoint: SpocsEndpointRaw + private lateinit var client: Client + + private lateinit var errorResponse: Response + private lateinit var successResponse: Response + private lateinit var defaultResponse: Response + + @Before + fun setUp() { + errorResponse = MockResponses.getError() + successResponse = MockResponses.getSuccess() + defaultResponse = errorResponse + + client = mock<Client>().also { + doReturn(defaultResponse).`when`(it).fetch(any()) + } + + whenever(sponsoredStoriesParams.siteId).thenReturn("") + whenever(sponsoredStoriesParams.country).thenReturn("") + whenever(sponsoredStoriesParams.city).thenReturn("") + + endpoint = SpocsEndpointRaw(client, profileId, appId, sponsoredStoriesParams) + } + + @Test + fun `GIVEN a PocketEndpointRaw THEN its visibility is internal`() { + assertClassVisibility(PocketEndpointRaw::class, KVisibility.INTERNAL) + } + + @Test + fun `GIVEN a debug build WHEN requesting spocs THEN the appropriate pocket proxy url is used`() { + SpocsEndpointRaw.isDebugBuild = true + val expectedUrl = "https://spocs.getpocket.dev/spocs" + + assertRequestParams( + client, + makeRequest = { + endpoint.getSponsoredStories() + }, + assertParams = { request -> + assertEquals(expectedUrl, request.url) + assertEquals(Request.Method.POST, request.method) + + val requestBody = JSONObject( + request.body!!.useStream { + it.bufferedReader().readText() + }, + ) + assertEquals(2, requestBody["version"]) + assertEquals(appId, requestBody["consumer_key"]) + assertEquals(profileId.toString(), requestBody["pocket_id"]) + + request.headers!!.first { + it.name.equals("Content-Type", true) + }.value.contains("application/json", true) + }, + ) + } + + @Test + fun `GIVEN a debug build AND a request configuration WHEN requesting spocs THEN the appropriate pocket proxy url is used`() { + SpocsEndpointRaw.isDebugBuild = true + val expectedUrl = "https://spocs.getpocket.dev/spocs?site=123&country=US&city=NY" + whenever(sponsoredStoriesParams.siteId).thenReturn("123") + whenever(sponsoredStoriesParams.country).thenReturn("US") + whenever(sponsoredStoriesParams.city).thenReturn("NY") + + assertRequestParams( + client, + makeRequest = { + endpoint.getSponsoredStories() + }, + assertParams = { request -> + assertEquals(expectedUrl, request.url) + assertEquals(Request.Method.POST, request.method) + + val requestBody = JSONObject( + request.body!!.useStream { + it.bufferedReader().readText() + }, + ) + assertEquals(2, requestBody["version"]) + assertEquals(appId, requestBody["consumer_key"]) + assertEquals(profileId.toString(), requestBody["pocket_id"]) + + request.headers!!.first { + it.name.equals("Content-Type", true) + }.value.contains("application/json", true) + }, + ) + } + + @Test + fun `GIVEN a release build WHEN requesting spocs THEN the appropriate pocket proxy url is used`() { + SpocsEndpointRaw.isDebugBuild = false + val expectedUrl = "https://spocs.getpocket.com/spocs" + + assertRequestParams( + client, + makeRequest = { + endpoint.getSponsoredStories() + }, + assertParams = { request -> + assertEquals(expectedUrl, request.url) + assertEquals(Request.Method.POST, request.method) + + val requestBody = JSONObject( + request.body!!.useStream { + it.bufferedReader().readText() + }, + ) + assertEquals(2, requestBody["version"]) + assertEquals(appId, requestBody["consumer_key"]) + assertEquals(profileId.toString(), requestBody["pocket_id"]) + + request.headers!!.first { + it.name.equals("Content-Type", true) + }.value.contains("application/json", true) + }, + ) + } + + @Test + fun `GIVEN a release build AND a request configuration WHEN requesting spocs THEN the appropriate pocket proxy url is used`() { + SpocsEndpointRaw.isDebugBuild = false + val expectedUrl = "https://spocs.getpocket.com/spocs?site=123&country=US&city=NY" + whenever(sponsoredStoriesParams.siteId).thenReturn("123") + whenever(sponsoredStoriesParams.country).thenReturn("US") + whenever(sponsoredStoriesParams.city).thenReturn("NY") + + assertRequestParams( + client, + makeRequest = { + endpoint.getSponsoredStories() + }, + assertParams = { request -> + assertEquals(expectedUrl, request.url) + assertEquals(Request.Method.POST, request.method) + + val requestBody = JSONObject( + request.body!!.useStream { + it.bufferedReader().readText() + }, + ) + assertEquals(2, requestBody["version"]) + assertEquals(appId, requestBody["consumer_key"]) + assertEquals(profileId.toString(), requestBody["pocket_id"]) + + request.headers!!.first { + it.name.equals("Content-Type", true) + }.value.contains("application/json", true) + }, + ) + } + + @Test + fun `WHEN requesting spocs and the client throws an IOException THEN null is returned`() { + doThrow(IOException::class.java).`when`(client).fetch(any()) + + assertNull(endpoint.getSponsoredStories()) + } + + @Test + fun `WHEN requesting spocs and the response is null THEN null is returned`() { + doReturn(null).`when`(client).fetch(any()) + + assertNull(endpoint.getSponsoredStories()) + } + + @Test + fun `WHEN requesting spocs and the response is not a success THEN null is returned`() { + doReturn(errorResponse).`when`(client).fetch(any()) + + assertNull(endpoint.getSponsoredStories()) + } + + @Test + fun `GIVEN a debug build WHEN requesting profile deletion THEN the appropriate pocket proxy url is used`() { + SpocsEndpointRaw.isDebugBuild = true + val expectedUrl = "https://spocs.getpocket.dev/user" + + assertRequestParams( + client, + makeRequest = { + endpoint.deleteProfile() + }, + assertParams = { request -> + assertEquals(expectedUrl, request.url) + assertEquals(Request.Method.DELETE, request.method) + }, + ) + } + + @Test + fun `GIVEN a release build WHEN requesting profile deletion THEN the appropriate pocket proxy url is used`() { + SpocsEndpointRaw.isDebugBuild = false + val expectedUrl = "https://spocs.getpocket.com/user" + + assertRequestParams( + client, + makeRequest = { + endpoint.deleteProfile() + }, + assertParams = { request -> + assertEquals(expectedUrl, request.url) + assertEquals(Request.Method.DELETE, request.method) + }, + ) + } + + @Test + fun `WHEN requesting profile deletion and the client throws an IOException THEN false is returned`() { + doThrow(IOException::class.java).`when`(client).fetch(any()) + + assertFalse(endpoint.deleteProfile()) + } + + @Test + fun `WHEN requesting account deletion and the response is not a success THEN false is returned`() { + doReturn(errorResponse).`when`(client).fetch(any()) + + assertFalse(endpoint.deleteProfile()) + } + + @Test + fun `WHEN requesting spocs and the response is a success THEN the response body is returned`() { + assertSuccessfulRequestReturnsResponseBody(client, endpoint::getSponsoredStories) + } + + @Test + fun `WHEN requesting profile deletion and the response is a success THEN true is returned`() { + val response = MockResponses.getSuccess() + doReturn(response).`when`(client).fetch(any()) + + assertTrue(endpoint.deleteProfile()) + } + + @Test + fun `WHEN requesting spocs and the response is an error THEN response is closed`() { + assertResponseIsClosed(client, errorResponse) { + endpoint.getSponsoredStories() + } + } + + @Test + fun `GIVEN a response from the request to delete profile WHEN inferring it's success THEN don't use the reponse body`() { + // Leverage the fact that a stream can only be read once to know if it was previously read. + + doReturn(errorResponse).`when`(client).fetch(any()) + errorResponse.use { "Only the response status should be used, not the response body" } + + doReturn(successResponse).`when`(client).fetch(any()) + successResponse.use { "Only the response status should be used, not the response body" } + } + + @Test + fun `WHEN requesting spocs and the response is a success THEN response is closed`() { + assertResponseIsClosed(client, successResponse) { + endpoint.getSponsoredStories() + } + } + + @Test + fun `WHEN newInstance is called THEN a new instance configured with the client provided is returned`() { + val result = Companion.newInstance(client) + + assertSame(client, result.client) + } + + @Test + fun `GIVEN a debug build WHEN querying the base url THEN use the development endpoint`() { + SpocsEndpointRaw.isDebugBuild = true + val expectedUrl = "https://spocs.getpocket.dev/" + + assertEquals(expectedUrl, SpocsEndpointRaw.baseUrl) + } + + @Test + fun `GIVEN a release build WHEN querying the base url THEN use the production endpoint`() { + SpocsEndpointRaw.isDebugBuild = false + val expectedUrl = "https://spocs.getpocket.com/" + + assertEquals(expectedUrl, SpocsEndpointRaw.baseUrl) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/api/SpocsEndpointTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/api/SpocsEndpointTest.kt new file mode 100644 index 0000000000..3d3e7188c4 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/api/SpocsEndpointTest.kt @@ -0,0 +1,161 @@ +/* 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.pocket.spocs.api + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.concept.fetch.Client +import mozilla.components.service.pocket.PocketStoriesRequestConfig +import mozilla.components.service.pocket.helpers.PocketTestResources +import mozilla.components.service.pocket.helpers.assertClassVisibility +import mozilla.components.service.pocket.helpers.assertResponseIsFailure +import mozilla.components.service.pocket.helpers.assertResponseIsSuccess +import mozilla.components.service.pocket.stories.api.PocketResponse +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +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.times +import org.mockito.Mockito.verify +import java.util.UUID +import kotlin.reflect.KVisibility + +@OptIn(ExperimentalCoroutinesApi::class) // for runTest +@RunWith(AndroidJUnit4::class) +class SpocsEndpointTest { + + private lateinit var endpoint: SpocsEndpoint + private var raw: SpocsEndpointRaw = mock() // we shorten the name to avoid confusion with endpoint. + private var jsonParser: SpocsJSONParser = mock() + private var client: Client = mock() + + @Before + fun setUp() { + endpoint = SpocsEndpoint(raw, jsonParser) + } + + @Test + fun `GIVEN a SpocsEndpoint THEN its visibility is internal`() { + assertClassVisibility(SpocsEndpoint::class, KVisibility.INTERNAL) + } + + @Test + fun `GIVEN a request for spocs WHEN getting a null response THEN a failure is returned`() = runTest { + doReturn(null).`when`(raw).getSponsoredStories() + + assertResponseIsFailure(endpoint.getSponsoredStories()) + } + + @Test + fun `GIVEN a request for spocs WHEN getting a null response THEN we do not attempt to parse stories`() = runTest { + doReturn(null).`when`(raw).getSponsoredStories() + + doThrow( + AssertionError("JSONParser should not be called for a null endpoint response"), + ).`when`(jsonParser).jsonToSpocs(any()) + + endpoint.getSponsoredStories() + } + + @Test + fun `GIVEN a request for deleting profile WHEN the response is unsuccessful THEN a failure is returned`() = runTest { + doReturn(false).`when`(raw).deleteProfile() + + assertResponseIsFailure(endpoint.deleteProfile()) + } + + @Test + fun `GIVEN a request for deleting profile WHEN the response is successful THEN success is returned`() = runTest { + doReturn(true).`when`(raw).deleteProfile() + + assertResponseIsSuccess(endpoint.deleteProfile()) + } + + @Test + fun `GIVEN a request for spocs WHEN getting an empty response THEN a failure is returned`() = runTest { + arrayOf( + "", + " ", + ).forEach { response -> + doReturn(response).`when`(raw).getSponsoredStories() + + assertResponseIsFailure(endpoint.getSponsoredStories()) + } + } + + @Test + fun `GIVEN a request for spocs WHEN getting an empty response THEN we do not attempt to parse stories`() = runTest { + arrayOf( + "", + " ", + ).forEach { response -> + doReturn(response).`when`(raw).getSponsoredStories() + doThrow( + AssertionError("JSONParser should not be called for an empty endpoint response"), + ).`when`(jsonParser).jsonToSpocs(any()) + + endpoint.getSponsoredStories() + } + } + + @Test + fun `GIVEN a request for stories WHEN getting a response THEN parse it through PocketJSONParser`() = runTest { + arrayOf( + "{}", + """{"expectedJSON": 101}""", + """{ "spocs": [] }""", + ).forEach { response -> + doReturn(response).`when`(raw).getSponsoredStories() + + endpoint.getSponsoredStories() + + verify(jsonParser, times(1)).jsonToSpocs(response) + } + } + + @Test + fun `GIVEN a request for stories WHEN getting a valid response THEN success is returned`() = runTest { + endpoint = SpocsEndpoint(raw, SpocsJSONParser) + val response = PocketTestResources.pocketEndpointThreeSpocsResponse + doReturn(response).`when`(raw).getSponsoredStories() + + val result = endpoint.getSponsoredStories() + + assertTrue(result is PocketResponse.Success) + } + + @Test + fun `GIVEN a request for stories WHEN getting a valid response THEN a success response with parsed stories is returned`() = runTest { + endpoint = SpocsEndpoint(raw, SpocsJSONParser) + val response = PocketTestResources.pocketEndpointThreeSpocsResponse + doReturn(response).`when`(raw).getSponsoredStories() + val expected = PocketTestResources.apiExpectedPocketSpocs + + val result = endpoint.getSponsoredStories() + + assertEquals(expected, (result as? PocketResponse.Success)?.data) + } + + @Test + fun `WHEN newInstance is called THEN a new SpocsEndpoint is returned as a wrapper over a configured SpocsEndpointRaw`() { + val profileId = UUID.randomUUID() + val appId = "test" + val sponsoredStoriesParams = PocketStoriesRequestConfig("123") + + val result = SpocsEndpoint.Companion.newInstance(client, profileId, appId, sponsoredStoriesParams) + + assertSame(client, result.rawEndpoint.client) + assertSame(profileId, result.rawEndpoint.profileId) + assertSame(appId, result.rawEndpoint.appId) + assertSame(sponsoredStoriesParams, result.rawEndpoint.sponsoredStoriesParams) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/api/SpocsJSONParserTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/api/SpocsJSONParserTest.kt new file mode 100644 index 0000000000..a49d9bd96e --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/api/SpocsJSONParserTest.kt @@ -0,0 +1,200 @@ +/* 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.pocket.spocs.api + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.service.pocket.helpers.PocketTestResources +import mozilla.components.service.pocket.helpers.assertClassVisibility +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.reflect.KVisibility + +@RunWith(AndroidJUnit4::class) +class SpocsJSONParserTest { + @Test + fun `GIVEN a SpocsJSONParser THEN its visibility is internal`() { + assertClassVisibility(SpocsJSONParser::class, KVisibility.INTERNAL) + } + + @Test + fun `GIVEN SpocsJSONParser WHEN parsing spocs THEN ApiSpocs are returned`() { + val expectedSpocs = PocketTestResources.apiExpectedPocketSpocs + val pocketJSON = PocketTestResources.pocketEndpointThreeSpocsResponse + val actualSpocs = SpocsJSONParser.jsonToSpocs(pocketJSON) + + assertNotNull(actualSpocs) + assertEquals(3, actualSpocs!!.size) + assertEquals(expectedSpocs, actualSpocs) + } + + @Test + fun `WHEN parsing spocs with missing titles THEN those entries are dropped`() { + val pocketJSON = PocketTestResources.pocketEndpointThreeSpocsResponse + val expectedSpocsIfMissingTitle = ArrayList(PocketTestResources.apiExpectedPocketSpocs) + .apply { removeAt(2) } + val pocketJsonWithMissingTitle = removeJsonFieldFromArrayIndex("title", 2, pocketJSON) + + val result = SpocsJSONParser.jsonToSpocs(pocketJsonWithMissingTitle) + + assertEquals(2, result!!.size) + assertEquals(expectedSpocsIfMissingTitle.joinToString(), result.joinToString()) + } + + @Test + fun `WHEN parsing spocs with missing urls THEN those entries are dropped`() { + val pocketJSON = PocketTestResources.pocketEndpointThreeSpocsResponse + val expectedSpocsIfMissingTitle = ArrayList(PocketTestResources.apiExpectedPocketSpocs) + .apply { removeAt(1) } + val pocketJsonWithMissingTitle = removeJsonFieldFromArrayIndex("url", 1, pocketJSON) + + val result = SpocsJSONParser.jsonToSpocs(pocketJsonWithMissingTitle) + + assertEquals(2, result!!.size) + assertEquals(expectedSpocsIfMissingTitle.joinToString(), result.joinToString()) + } + + @Test + fun `WHEN parsing spocs with missing image urls THEN those entries are dropped`() { + val pocketJSON = PocketTestResources.pocketEndpointThreeSpocsResponse + val expectedSpocsIfMissingTitle = ArrayList(PocketTestResources.apiExpectedPocketSpocs) + .apply { removeAt(0) } + val pocketJsonWithMissingTitle = removeJsonFieldFromArrayIndex("image_src", 0, pocketJSON) + + val result = SpocsJSONParser.jsonToSpocs(pocketJsonWithMissingTitle) + + assertEquals(2, result!!.size) + assertEquals(expectedSpocsIfMissingTitle.joinToString(), result.joinToString()) + } + + @Test + fun `WHEN parsing spocs with missing sponsors THEN those entries are dropped`() { + val pocketJSON = PocketTestResources.pocketEndpointThreeSpocsResponse + val expectedSpocsIfMissingTitle = ArrayList(PocketTestResources.apiExpectedPocketSpocs) + .apply { removeAt(1) } + val pocketJsonWithMissingTitle = removeJsonFieldFromArrayIndex("sponsor", 1, pocketJSON) + + val result = SpocsJSONParser.jsonToSpocs(pocketJsonWithMissingTitle) + + assertEquals(2, result!!.size) + assertEquals(expectedSpocsIfMissingTitle.joinToString(), result.joinToString()) + } + + @Test + fun `WHEN parsing spocs with missing click shims THEN those entries are dropped`() { + val pocketJSON = PocketTestResources.pocketEndpointThreeSpocsResponse + val expectedSpocsIfMissingTitle = ArrayList(PocketTestResources.apiExpectedPocketSpocs) + .apply { removeAt(2) } + val pocketJsonWithMissingTitle = removeShimFromSpoc("click", 2, pocketJSON) + + val result = SpocsJSONParser.jsonToSpocs(pocketJsonWithMissingTitle) + + assertEquals(2, result!!.size) + assertEquals(expectedSpocsIfMissingTitle.joinToString(), result.joinToString()) + } + + @Test + fun `WHEN parsing spocs with missing impression shims THEN those entries are dropped`() { + val pocketJSON = PocketTestResources.pocketEndpointThreeSpocsResponse + val expectedSpocsIfMissingTitle = ArrayList(PocketTestResources.apiExpectedPocketSpocs) + .apply { removeAt(1) } + val pocketJsonWithMissingTitle = removeShimFromSpoc("impression", 1, pocketJSON) + + val result = SpocsJSONParser.jsonToSpocs(pocketJsonWithMissingTitle) + + assertEquals(2, result!!.size) + assertEquals(expectedSpocsIfMissingTitle.joinToString(), result.joinToString()) + } + + @Test + fun `WHEN parsing spocs with missing priority THEN those entries are dropped`() { + val pocketJSON = PocketTestResources.pocketEndpointThreeSpocsResponse + val expectedSpocsIfMissingPriority = ArrayList(PocketTestResources.apiExpectedPocketSpocs) + .apply { removeAt(1) } + val pocketJsonWithMissingPriority = removeJsonFieldFromArrayIndex("priority", 1, pocketJSON) + + val result = SpocsJSONParser.jsonToSpocs(pocketJsonWithMissingPriority) + + assertEquals(2, result!!.size) + assertEquals(expectedSpocsIfMissingPriority.joinToString(), result.joinToString()) + } + + @Test + fun `WHEN parsing spocs with missing a lifetime count cap THEN those entries are dropped`() { + val pocketJSON = PocketTestResources.pocketEndpointThreeSpocsResponse + val expectedSpocsIfMissingLifetimeCap = ArrayList(PocketTestResources.apiExpectedPocketSpocs) + .apply { removeAt(0) } + val pocketJsonWithMissingLifetimeCap = removeCapFromSpoc(JSON_SPOC_CAPS_LIFETIME_KEY, 0, pocketJSON) + + val result = SpocsJSONParser.jsonToSpocs(pocketJsonWithMissingLifetimeCap) + + assertEquals(2, result!!.size) + assertEquals(expectedSpocsIfMissingLifetimeCap.joinToString(), result.joinToString()) + } + + @Test + fun `WHEN parsing spocs with missing a flight count cap THEN those entries are dropped`() { + val pocketJSON = PocketTestResources.pocketEndpointThreeSpocsResponse + val expectedSpocsIfMissingFlightCountCap = ArrayList(PocketTestResources.apiExpectedPocketSpocs) + .apply { removeAt(1) } + val pocketJsonWithMissingFlightCountCap = removeCapFromSpoc(JSON_SPOC_CAPS_FLIGHT_COUNT_KEY, 1, pocketJSON) + + val result = SpocsJSONParser.jsonToSpocs(pocketJsonWithMissingFlightCountCap) + + assertEquals(2, result!!.size) + assertEquals(expectedSpocsIfMissingFlightCountCap.joinToString(), result.joinToString()) + } + + @Test + fun `WHEN parsing spocs with missing a flight period cap THEN those entries are dropped`() { + val pocketJSON = PocketTestResources.pocketEndpointThreeSpocsResponse + val expectedSpocsIfMissingFlightPeriodCap = ArrayList(PocketTestResources.apiExpectedPocketSpocs) + .apply { removeAt(2) } + val pocketJsonWithMissingFlightPeriodCap = removeCapFromSpoc(JSON_SPOC_CAPS_FLIGHT_PERIOD_KEY, 2, pocketJSON) + + val result = SpocsJSONParser.jsonToSpocs(pocketJsonWithMissingFlightPeriodCap) + + assertEquals(2, result!!.size) + assertEquals(expectedSpocsIfMissingFlightPeriodCap.joinToString(), result.joinToString()) + } + + @Test + fun `WHEN parsing spocs for an invalid JSON String THEN null is returned`() { + assertNull(SpocsJSONParser.jsonToSpocs("{!!}}")) + } +} + +private fun removeJsonFieldFromArrayIndex(fieldName: String, indexInArray: Int, json: String): String { + val obj = JSONObject(json) + val spocsJson = obj.getJSONArray(KEY_ARRAY_SPOCS) + spocsJson.getJSONObject(indexInArray).remove(fieldName) + return obj.toString() +} + +private fun removeShimFromSpoc(shimName: String, spocIndex: Int, json: String): String { + val obj = JSONObject(json) + val spocsJson = obj.getJSONArray(KEY_ARRAY_SPOCS) + val spocJson = spocsJson.getJSONObject(spocIndex) + spocJson.getJSONObject(JSON_SPOC_SHIMS_KEY).remove(shimName) + return obj.toString() +} + +private fun removeCapFromSpoc(cap: String, spocIndex: Int, json: String): String { + val obj = JSONObject(json) + val spocsJson = obj.getJSONArray(KEY_ARRAY_SPOCS) + val spocJson = spocsJson.getJSONObject(spocIndex) + val capsJSON = spocJson.getJSONObject(JSON_SPOC_CAPS_KEY) + + if (cap == JSON_SPOC_CAPS_LIFETIME_KEY) { + capsJSON.remove(cap) + } else { + capsJSON.getJSONObject(JSON_SPOC_CAPS_FLIGHT_KEY).remove(cap) + } + + return obj.toString() +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/db/SpocEntityTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/db/SpocEntityTest.kt new file mode 100644 index 0000000000..f7dee01418 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/db/SpocEntityTest.kt @@ -0,0 +1,17 @@ +/* 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.pocket.spocs.db + +import mozilla.components.service.pocket.helpers.assertClassVisibility +import org.junit.Test +import kotlin.reflect.KVisibility.INTERNAL + +class SpocEntityTest { + // This is the data type persisted locally. No need to be public + @Test + fun `GIVEN a spoc entity THEN it's visibility is internal`() { + assertClassVisibility(SpocEntity::class, INTERNAL) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/db/SpocImpressionEntityTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/db/SpocImpressionEntityTest.kt new file mode 100644 index 0000000000..4e119b0bb2 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/db/SpocImpressionEntityTest.kt @@ -0,0 +1,29 @@ +/* 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.pocket.spocs.db + +import mozilla.components.service.pocket.helpers.assertClassVisibility +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.reflect.KVisibility.INTERNAL + +class SpocImpressionEntityTest { + // This is the data type persisted locally. No need to be public + @Test + fun `GIVEN a spoc entity THEN it's visibility is internal`() { + assertClassVisibility(SpocImpressionEntity::class, INTERNAL) + } + + @Test + fun `WHEN a new impression is created THEN the timestamp should be seconds from Epoch`() { + val nowInSeconds = System.currentTimeMillis() / 1000 + val impression = SpocImpressionEntity(2) + + assertTrue( + LongRange(nowInSeconds - 5, nowInSeconds + 5) + .contains(impression.impressionDateInSeconds), + ) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/db/SpocsDaoTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/db/SpocsDaoTest.kt new file mode 100644 index 0000000000..b4bd5e6c45 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/spocs/db/SpocsDaoTest.kt @@ -0,0 +1,513 @@ +/* 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.pocket.spocs.db + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.service.pocket.helpers.PocketTestResources +import mozilla.components.service.pocket.stories.db.PocketRecommendationsDatabase +import mozilla.components.support.test.robolectric.testContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class SpocsDaoTest { + private lateinit var database: PocketRecommendationsDatabase + private lateinit var dao: SpocsDao + private lateinit var executor: ExecutorService + + @get:Rule + var instantTaskExecutorRule = InstantTaskExecutorRule() + + @Before + fun setUp() { + executor = Executors.newSingleThreadExecutor() + database = Room + .inMemoryDatabaseBuilder(testContext, PocketRecommendationsDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = database.spocsDao() + } + + @After + fun tearDown() { + database.close() + executor.shutdown() + } + + @Test + fun `GIVEN an empty table WHEN a story is inserted and then queried THEN return the same story`() = runTest { + val story = PocketTestResources.dbExpectedPocketSpoc + + dao.insertSpocs(listOf(story)) + val result = dao.getAllSpocs() + + assertEquals(listOf(story), result) + } + + @Test + fun `GIVEN a story already persisted WHEN another story with different id is tried to be inserted THEN add that to the table`() = runTest { + val story = PocketTestResources.dbExpectedPocketSpoc + val newStory = story.copy( + id = 1, + ) + dao.insertSpocs(listOf(story)) + + dao.insertSpocs(listOf(newStory)) + val result = dao.getAllSpocs() + + assertEquals(listOf(newStory, story), result) + } + + @Test + fun `GIVEN a story already persisted WHEN another story with different url is tried to be inserted THEN replace the existing`() = runTest { + val story = PocketTestResources.dbExpectedPocketSpoc + val newStory = story.copy( + title = "updated" + story.url, + ) + dao.insertSpocs(listOf(story)) + + dao.insertSpocs(listOf(newStory)) + val result = dao.getAllSpocs() + + assertEquals(listOf(newStory), result) + } + + @Test + fun `GIVEN a story already persisted WHEN another story with different title is tried to be inserted THEN replace the existing`() = runTest { + val story = PocketTestResources.dbExpectedPocketSpoc + val newStory = story.copy( + title = "updated" + story.title, + ) + dao.insertSpocs(listOf(story)) + + dao.insertSpocs(listOf(newStory)) + val result = dao.getAllSpocs() + + assertEquals(listOf(newStory), result) + } + + @Test + fun `GIVEN a story already persisted WHEN another story with different image url is tried to be inserted THEN replace the existing`() = runTest { + val story = PocketTestResources.dbExpectedPocketSpoc + val newStory = story.copy( + imageUrl = "updated" + story.imageUrl, + ) + dao.insertSpocs(listOf(story)) + + dao.insertSpocs(listOf(newStory)) + val result = dao.getAllSpocs() + + assertEquals(listOf(newStory), result) + } + + @Test + fun `GIVEN a story already persisted WHEN another story with different sponsor is tried to be inserted THEN replace the existing`() = runTest { + val story = PocketTestResources.dbExpectedPocketSpoc + val newStory = story.copy( + sponsor = "updated" + story.sponsor, + ) + dao.insertSpocs(listOf(story)) + + dao.insertSpocs(listOf(newStory)) + val result = dao.getAllSpocs() + + assertEquals(listOf(newStory), result) + } + + @Test + fun `GIVEN a story already persisted WHEN another story with different click shim is tried to be inserted THEN replace the existing`() = runTest { + val story = PocketTestResources.dbExpectedPocketSpoc + val newStory = story.copy( + clickShim = "updated" + story.clickShim, + ) + dao.insertSpocs(listOf(story)) + + dao.insertSpocs(listOf(newStory)) + val result = dao.getAllSpocs() + + assertEquals(listOf(newStory), result) + } + + @Test + fun `GIVEN a story already persisted WHEN another story with different impression shim is tried to be inserted THEN replace the existing`() = runTest { + val story = PocketTestResources.dbExpectedPocketSpoc + val newStory = story.copy( + impressionShim = "updated" + story.impressionShim, + ) + dao.insertSpocs(listOf(story)) + + dao.insertSpocs(listOf(newStory)) + val result = dao.getAllSpocs() + + assertEquals(listOf(newStory), result) + } + + @Test + fun `GIVEN a story already persisted WHEN another story with different priority is tried to be inserted THEN replace the existing`() = runTest { + val story = PocketTestResources.dbExpectedPocketSpoc + val newStory = story.copy( + priority = 765, + ) + dao.insertSpocs(listOf(story)) + + dao.insertSpocs(listOf(newStory)) + val result = dao.getAllSpocs() + + assertEquals(listOf(newStory), result) + } + + @Test + fun `GIVEN a story already persisted WHEN another story with a different lifetime cap count is tried to be inserted THEN replace the existing`() = runTest { + val story = PocketTestResources.dbExpectedPocketSpoc + val newStory = story.copy( + lifetimeCapCount = 123, + ) + dao.insertSpocs(listOf(story)) + + dao.insertSpocs(listOf(newStory)) + val result = dao.getAllSpocs() + + assertEquals(listOf(newStory), result) + } + + @Test + fun `GIVEN a story already persisted WHEN another story with a different flight count cap is tried to be inserted THEN replace the existing`() = runTest { + val story = PocketTestResources.dbExpectedPocketSpoc + val newStory = story.copy( + flightCapCount = 999, + ) + dao.insertSpocs(listOf(story)) + + dao.insertSpocs(listOf(newStory)) + val result = dao.getAllSpocs() + + assertEquals(listOf(newStory), result) + } + + @Test + fun `GIVEN a story already persisted WHEN another story with a different flight period cap is tried to be inserted THEN replace the existing`() = runTest { + val story = PocketTestResources.dbExpectedPocketSpoc + val newStory = story.copy( + flightCapPeriod = 1, + ) + dao.insertSpocs(listOf(story)) + + dao.insertSpocs(listOf(newStory)) + val result = dao.getAllSpocs() + + assertEquals(listOf(newStory), result) + } + + @Test + fun `GIVEN no persisted storied WHEN asked to insert a list of stories THEN add them all to the table`() = runTest { + val story1 = PocketTestResources.dbExpectedPocketSpoc + val story2 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 2) + val story3 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 3) + val story4 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 4) + + dao.insertSpocs(listOf(story1, story2, story3, story4)) + val result = dao.getAllSpocs() + + assertEquals(listOf(story1, story2, story3, story4), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to delete them THEN remove all from the table`() = runTest { + val story1 = PocketTestResources.dbExpectedPocketSpoc + val story2 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 2) + val story3 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 3) + val story4 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 4) + dao.insertSpocs(listOf(story1, story2, story3, story4)) + + dao.deleteAllSpocs() + val result = dao.getAllSpocs() + + assertTrue(result.isEmpty()) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to delete some THEN remove remove the ones already persisted`() = runTest { + val story1 = PocketTestResources.dbExpectedPocketSpoc + val story2 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 2) + val story3 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 3) + val story4 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 4) + val story5 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 5) + dao.insertSpocs(listOf(story1, story2, story3, story4)) + + dao.deleteSpocs(listOf(story2, story3, story5)) + val result = dao.getAllSpocs() + + assertEquals(listOf(story1, story4), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN remove from table all stories not found in the new list`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketSpoc + val story2 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 2) + val story3 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 3) + val story4 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 4) + dao.insertSpocs(listOf(story1, story2, story3, story4)) + + dao.cleanOldAndInsertNewSpocs(listOf(story2, story4)) + val result = dao.getAllSpocs() + + assertEquals(listOf(story2, story4), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN update stories with new ids`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketSpoc + val story2 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 2) + val updatedStory1 = story1.copy( + id = story1.id * 3, + ) + dao.insertSpocs(listOf(story1, story2)) + + dao.cleanOldAndInsertNewSpocs(listOf(updatedStory1, story2)) + val result = dao.getAllSpocs() + + // Order gets reversed because the original story is replaced and another one is added. + assertEquals(listOf(story2, updatedStory1), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN don't update story if only url changed`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketSpoc + val story2 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 2) + val updatedStory1 = story1.copy( + url = "updated" + story1.url, + ) + dao.insertSpocs(listOf(story1, story2)) + + dao.cleanOldAndInsertNewSpocs(listOf(updatedStory1, story2)) + val result = dao.getAllSpocs() + + assertEquals(listOf(updatedStory1, story2), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN don't update story if only title changed`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketSpoc + val story2 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 2) + val updatedStory1 = story1.copy( + title = "updated" + story1.title, + ) + dao.insertSpocs(listOf(story1, story2)) + + dao.cleanOldAndInsertNewSpocs(listOf(updatedStory1, story2)) + val result = dao.getAllSpocs() + + assertEquals(listOf(updatedStory1, story2), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN don't update story if only image url changed`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketSpoc + val story2 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 2) + val updatedStory1 = story1.copy( + imageUrl = "updated" + story1.imageUrl, + ) + dao.insertSpocs(listOf(story1, story2)) + + dao.cleanOldAndInsertNewSpocs(listOf(updatedStory1, story2)) + val result = dao.getAllSpocs() + + assertEquals(listOf(updatedStory1, story2), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN don't update story if only sponsor changed`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketSpoc + val story2 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 2) + val updatedStory1 = story1.copy( + sponsor = "updated" + story1.sponsor, + ) + dao.insertSpocs(listOf(story1, story2)) + + dao.cleanOldAndInsertNewSpocs(listOf(updatedStory1, story2)) + val result = dao.getAllSpocs() + + assertEquals(listOf(updatedStory1, story2), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN don't update story if only the click shim changed`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketSpoc + val story2 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 2) + val updatedStory1 = story1.copy( + clickShim = "updated" + story1.clickShim, + ) + dao.insertSpocs(listOf(story1, story2)) + + dao.cleanOldAndInsertNewSpocs(listOf(updatedStory1, story2)) + val result = dao.getAllSpocs() + + assertEquals(listOf(updatedStory1, story2), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN don't update story if only the impression shim changed`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketSpoc + val story2 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 2) + val updatedStory1 = story1.copy( + impressionShim = "updated" + story1.impressionShim, + ) + dao.insertSpocs(listOf(story1, story2)) + + dao.cleanOldAndInsertNewSpocs(listOf(updatedStory1, story2)) + val result = dao.getAllSpocs() + + assertEquals(listOf(updatedStory1, story2), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN don't update story if only priority changed`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketSpoc + val story2 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 2) + val updatedStory1 = story1.copy( + priority = 678, + ) + dao.insertSpocs(listOf(story1, story2)) + + dao.cleanOldAndInsertNewSpocs(listOf(updatedStory1, story2)) + val result = dao.getAllSpocs() + + assertEquals(listOf(updatedStory1, story2), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN don't update story if only the lifetime count cap changed`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketSpoc + val story2 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 2) + val updatedStory1 = story1.copy( + lifetimeCapCount = 4322, + ) + dao.insertSpocs(listOf(story1, story2)) + + dao.cleanOldAndInsertNewSpocs(listOf(updatedStory1, story2)) + val result = dao.getAllSpocs() + + assertEquals(listOf(updatedStory1, story2), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN don't update story if only the flight count cap changed`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketSpoc + val story2 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 2) + val updatedStory1 = story1.copy( + flightCapCount = 111111, + ) + dao.insertSpocs(listOf(story1, story2)) + + dao.cleanOldAndInsertNewSpocs(listOf(updatedStory1, story2)) + val result = dao.getAllSpocs() + + assertEquals(listOf(updatedStory1, story2), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN don't update story if only the flight period cap changed`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketSpoc + val story2 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 2) + val updatedStory1 = story1.copy( + flightCapPeriod = 7, + ) + dao.insertSpocs(listOf(story1, story2)) + + dao.cleanOldAndInsertNewSpocs(listOf(updatedStory1, story2)) + val result = dao.getAllSpocs() + + assertEquals(listOf(updatedStory1, story2), result) + } + + @Test + fun `GIVEN no stories are persisted WHEN asked to record an impression THEN don't persist data and don't throw errors`() = runTest { + dao.recordImpression(6543321) + + val result = dao.getSpocsImpressions() + + assertTrue(result.isEmpty()) + } + + @Test + fun `GIVEN stories are persisted WHEN asked to record impressions for other stories also THEN persist impression only for existing stories`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketSpoc + val story2 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 2) + val story3 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 3) + dao.insertSpocs(listOf(story1, story3)) + + dao.recordImpressions( + listOf( + SpocImpressionEntity(story1.id), + SpocImpressionEntity(story2.id), + SpocImpressionEntity(story3.id), + ), + ) + val result = dao.getSpocsImpressions() + + assertEquals(2, result.size) + assertEquals(story1.id, result[0].spocId) + assertEquals(story3.id, result[1].spocId) + } + + @Test + fun `GIVEN stories are persisted WHEN asked to record impressions for existing stories THEN persist the impressions`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketSpoc + val story2 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 2) + val story3 = PocketTestResources.dbExpectedPocketSpoc.copy(id = story1.id * 3) + dao.insertSpocs(listOf(story1, story2, story3)) + + dao.recordImpressions( + listOf( + SpocImpressionEntity(story1.id), + SpocImpressionEntity(story3.id), + ), + ) + val result = dao.getSpocsImpressions() + + assertEquals(2, result.size) + assertEquals(story1.id, result[0].spocId) + assertEquals(story3.id, result[1].spocId) + } + + /** + * Sets an executor to be used for database transactions. + * Needs to be used along with "runTest" to ensure waiting for transactions to finish but not hang tests. + */ + private fun setupDatabseForTransactions() { + database = Room + .inMemoryDatabaseBuilder(testContext, PocketRecommendationsDatabase::class.java) + .setTransactionExecutor(executor) + .allowMainThreadQueries() + .build() + dao = database.spocsDao() + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/PocketRecommendationsRepositoryTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/PocketRecommendationsRepositoryTest.kt new file mode 100644 index 0000000000..9e5b287ee0 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/PocketRecommendationsRepositoryTest.kt @@ -0,0 +1,75 @@ +/* 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.pocket.stories + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.service.pocket.ext.toPartialTimeShownUpdate +import mozilla.components.service.pocket.ext.toPocketLocalStory +import mozilla.components.service.pocket.ext.toPocketRecommendedStory +import mozilla.components.service.pocket.helpers.PocketTestResources +import mozilla.components.service.pocket.stories.db.PocketRecommendationsDao +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +@ExperimentalCoroutinesApi // for runTest +@RunWith(AndroidJUnit4::class) +class PocketRecommendationsRepositoryTest { + + private val pocketRepo = spy(PocketRecommendationsRepository(testContext)) + private lateinit var dao: PocketRecommendationsDao + + @Before + fun setUp() { + dao = mock(PocketRecommendationsDao::class.java) + `when`(pocketRepo.pocketRecommendationsDao).thenReturn(dao) + } + + @Test + fun `GIVEN PocketRecommendationsRepository WHEN getPocketRecommendedStories is called THEN return db entities mapped to domain type`() { + runTest { + val dbStory = PocketTestResources.dbExpectedPocketStory + `when`(dao.getPocketStories()).thenReturn(listOf(dbStory)) + + val result = pocketRepo.getPocketRecommendedStories() + + verify(dao).getPocketStories() + assertEquals(1, result.size) + assertEquals(dbStory.toPocketRecommendedStory(), result[0]) + } + } + + @Test + fun `GIVEN PocketRecommendationsRepository WHEN addAllPocketApiStories is called THEN persist the received story to db`() { + runTest { + val apiStories = PocketTestResources.apiExpectedPocketStoriesRecommendations + val apiStoriesMappedForDb = apiStories.map { it.toPocketLocalStory() } + + pocketRepo.addAllPocketApiStories(apiStories) + + verify(dao).cleanOldAndInsertNewPocketStories(apiStoriesMappedForDb) + } + } + + @Test + fun `GIVEN PocketRecommendationsRepository WHEN updateShownPocketRecommendedStories should persist the received story to db`() { + runTest { + val clientStories = listOf(PocketTestResources.clientExpectedPocketStory) + val clientStoriesPartialUpdate = clientStories.map { it.toPartialTimeShownUpdate() } + + pocketRepo.updateShownPocketRecommendedStories(clientStories) + + verify(dao).updateTimesShown(clientStoriesPartialUpdate) + } + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/PocketStoriesUseCasesTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/PocketStoriesUseCasesTest.kt new file mode 100644 index 0000000000..4af13135a8 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/PocketStoriesUseCasesTest.kt @@ -0,0 +1,197 @@ +/* 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.pocket.stories + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.concept.fetch.Client +import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory +import mozilla.components.service.pocket.helpers.PocketTestResources +import mozilla.components.service.pocket.helpers.assertClassVisibility +import mozilla.components.service.pocket.stories.api.PocketEndpoint +import mozilla.components.service.pocket.stories.api.PocketResponse +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import kotlin.reflect.KVisibility + +@ExperimentalCoroutinesApi // for runTest +@RunWith(AndroidJUnit4::class) +class PocketStoriesUseCasesTest { + private val fetchClient: Client = mock() + private val useCases = spy(PocketStoriesUseCases(testContext, fetchClient)) + private val pocketRepo: PocketRecommendationsRepository = mock() + private val pocketEndoint: PocketEndpoint = mock() + + @Before + fun setup() { + doReturn(pocketEndoint).`when`(useCases).getPocketEndpoint(any()) + doReturn(pocketRepo).`when`(useCases).getPocketRepository(any()) + } + + @Test + fun `GIVEN a PocketStoriesUseCases THEN its visibility is internal`() { + assertClassVisibility(PocketStoriesUseCases::class, KVisibility.INTERNAL) + } + + @Test + fun `GIVEN a RefreshPocketStories THEN its visibility is internal`() { + assertClassVisibility( + PocketStoriesUseCases.RefreshPocketStories::class, + KVisibility.INTERNAL, + ) + } + + @Test + fun `GIVEN a GetPocketStories THEN its visibility is public`() { + assertClassVisibility(PocketStoriesUseCases.GetPocketStories::class, KVisibility.INTERNAL) + } + + @Test + fun `GIVEN PocketStoriesUseCases WHEN RefreshPocketStories is constructed THEN use the same parameters`() { + val refreshUseCase = useCases.refreshStories + + assertSame(testContext, refreshUseCase.appContext) + assertSame(fetchClient, refreshUseCase.fetchClient) + } + + @Test + fun `GIVEN PocketStoriesUseCases constructed WHEN RefreshPocketStories is constructed separately THEN default to use the same parameters`() { + val refreshUseCase = useCases.RefreshPocketStories() + + assertSame(testContext, refreshUseCase.appContext) + assertSame(fetchClient, refreshUseCase.fetchClient) + } + + @Test + fun `GIVEN PocketStoriesUseCases constructed WHEN RefreshPocketStories is constructed separately THEN allow using different parameters`() { + val context2: Context = mock() + val fetchClient2: Client = mock() + + val refreshUseCase = useCases.RefreshPocketStories(context2, fetchClient2) + + assertSame(context2, refreshUseCase.appContext) + assertSame(fetchClient2, refreshUseCase.fetchClient) + } + + @Test + fun `GIVEN PocketStoriesUseCases WHEN RefreshPocketStories is called THEN download stories from API and return early if unsuccessful response`() = runTest { + val refreshUseCase = useCases.RefreshPocketStories() + val successfulResponse = getSuccessfulPocketStories() + doReturn(successfulResponse).`when`(pocketEndoint).getRecommendedStories() + + val result = refreshUseCase.invoke() + + assertTrue(result) + verify(pocketEndoint).getRecommendedStories() + verify(pocketRepo).addAllPocketApiStories((successfulResponse as PocketResponse.Success).data) + } + + @Test + fun `GIVEN PocketStoriesUseCases WHEN RefreshPocketStories is called THEN download stories from API and save a successful response locally`() = runTest { + val refreshUseCase = useCases.RefreshPocketStories() + val successfulResponse = getFailedPocketStories() + doReturn(successfulResponse).`when`(pocketEndoint).getRecommendedStories() + + val result = refreshUseCase.invoke() + + assertFalse(result) + verify(pocketEndoint).getRecommendedStories() + verify(pocketRepo, never()).addAllPocketApiStories(any()) + } + + @Test + fun `GIVEN PocketStoriesUseCases WHEN GetPocketStories is constructed THEN use the same parameters`() { + val getStoriesUseCase = useCases.getStories + + assertSame(testContext, getStoriesUseCase.context) + } + + @Test + fun `GIVEN PocketStoriesUseCases constructed WHEN GetPocketStories is constructed separately THEN default to use the same parameters`() { + val getStoriesUseCase = useCases.GetPocketStories() + + assertSame(testContext, getStoriesUseCase.context) + } + + @Test + fun `GIVEN PocketStoriesUseCases constructed WHEN GetPocketStories is constructed separately THEN allow using different parameters`() { + val context2: Context = mock() + + val getStoriesUseCase = useCases.GetPocketStories(context2) + + assertSame(context2, getStoriesUseCase.context) + } + + @Test + fun `GIVEN PocketStoriesUseCases WHEN GetPocketStories is called THEN delegate the repository to return locally stored stories`() = + runTest { + val getStoriesUseCase = useCases.GetPocketStories() + doReturn(emptyList<PocketRecommendedStory>()).`when`(pocketRepo) + .getPocketRecommendedStories() + var result = getStoriesUseCase.invoke() + verify(pocketRepo).getPocketRecommendedStories() + assertTrue(result.isEmpty()) + + val stories = listOf(PocketTestResources.clientExpectedPocketStory) + doReturn(stories).`when`(pocketRepo).getPocketRecommendedStories() + result = getStoriesUseCase.invoke() + // getPocketRecommendedStories() should've been called 2 times. Once in the above check, once now. + verify(pocketRepo, times(2)).getPocketRecommendedStories() + assertEquals(result, stories) + } + + @Test + fun `GIVEN PocketStoriesUseCases WHEN UpdateStoriesTimesShown is constructed THEN use the same parameters`() { + val updateStoriesTimesShown = useCases.updateTimesShown + + assertSame(testContext, updateStoriesTimesShown.context) + } + + @Test + fun `GIVEN PocketStoriesUseCases constructed WHEN UpdateStoriesTimesShown is constructed separately THEN default to use the same parameters`() { + val updateStoriesTimesShown = useCases.UpdateStoriesTimesShown() + + assertSame(testContext, updateStoriesTimesShown.context) + } + + @Test + fun `GIVEN PocketStoriesUseCases constructed WHEN UpdateStoriesTimesShown is constructed separately THEN allow using different parameters`() { + val context2: Context = mock() + + val updateStoriesTimesShown = useCases.UpdateStoriesTimesShown(context2) + + assertSame(context2, updateStoriesTimesShown.context) + } + + @Test + fun `GIVEN PocketStoriesUseCases WHEN UpdateStoriesTimesShown is called THEN delegate the repository to update the stories shown`() = runTest { + val updateStoriesTimesShown = useCases.UpdateStoriesTimesShown() + val updatedStories: List<PocketRecommendedStory> = mock() + + updateStoriesTimesShown.invoke(updatedStories) + + verify(pocketRepo).updateShownPocketRecommendedStories(updatedStories) + } + + private fun getSuccessfulPocketStories() = + PocketResponse.wrap(PocketTestResources.apiExpectedPocketStoriesRecommendations) + + private fun getFailedPocketStories() = PocketResponse.wrap(null) +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/api/PocketApiStoryTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/api/PocketApiStoryTest.kt new file mode 100644 index 0000000000..34960a83d1 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/api/PocketApiStoryTest.kt @@ -0,0 +1,17 @@ +/* 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.pocket.stories.api + +import mozilla.components.service.pocket.helpers.assertClassVisibility +import org.junit.Test +import kotlin.reflect.KVisibility + +class PocketApiStoryTest { + // This is the data type as received from the Pocket endpoint. No need to be public. + @Test + fun `GIVEN a PocketRecommendedStory THEN its visibility is internal`() { + assertClassVisibility(PocketApiStory::class, KVisibility.INTERNAL) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/api/PocketEndpointRawTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/api/PocketEndpointRawTest.kt new file mode 100644 index 0000000000..e438b8c849 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/api/PocketEndpointRawTest.kt @@ -0,0 +1,115 @@ +/* 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.pocket.stories.api + +import androidx.core.net.toUri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Response +import mozilla.components.service.pocket.helpers.MockResponses +import mozilla.components.service.pocket.helpers.assertClassVisibility +import mozilla.components.service.pocket.helpers.assertRequestParams +import mozilla.components.service.pocket.helpers.assertResponseIsClosed +import mozilla.components.service.pocket.helpers.assertSuccessfulRequestReturnsResponseBody +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException +import kotlin.reflect.KVisibility + +@RunWith(AndroidJUnit4::class) +class PocketEndpointRawTest { + private val url = "https://mozilla.org".toUri() + + private lateinit var endpoint: PocketEndpointRaw + private lateinit var client: Client + + private lateinit var errorResponse: Response + private lateinit var successResponse: Response + private lateinit var defaultResponse: Response + + @Before + fun setUp() { + errorResponse = MockResponses.getError() + successResponse = MockResponses.getSuccess() + defaultResponse = errorResponse + + client = mock<Client>().also { + whenever(it.fetch(any())).thenReturn(defaultResponse) + } + + endpoint = PocketEndpointRaw(client) + } + + @Test + fun `GIVEN a PocketEndpointRaw THEN its visibility is internal`() { + assertClassVisibility(PocketEndpointRaw::class, KVisibility.INTERNAL) + } + + @Test + fun `WHEN requesting stories recommendations THEN the firefox android home recommendations url is used`() { + val expectedUrl = "https://firefox-android-home-recommendations.getpocket.com/" + + assertRequestParams( + client, + makeRequest = { + endpoint.getRecommendedStories() + }, + assertParams = { request -> + assertEquals(expectedUrl, request.url) + }, + ) + } + + @Test + fun `WHEN requesting stories recommendations and the client throws an IOException THEN null is returned`() { + whenever(client.fetch(any())).thenThrow(IOException::class.java) + assertNull(endpoint.getRecommendedStories()) + } + + @Test + fun `WHEN requesting stories recommendations and the response is null THEN null is returned`() { + whenever(client.fetch(any())).thenReturn(null) + assertNull(endpoint.getRecommendedStories()) + } + + @Test + fun `WHEN requesting stories recommendations and the response is not a success THEN null is returned`() { + whenever(client.fetch(any())).thenReturn(errorResponse) + assertNull(endpoint.getRecommendedStories()) + } + + @Test + fun `WHEN requesting stories recommendations and the response is a success THEN the response body is returned`() { + assertSuccessfulRequestReturnsResponseBody(client, endpoint::getRecommendedStories) + } + + @Test + fun `WHEN requesting stories recommendations and the response is an error THEN response is closed`() { + assertResponseIsClosed(client, errorResponse) { + endpoint.getRecommendedStories() + } + } + + @Test + fun `WHEN requesting stories recommendations and the response is a success THEN response is closed`() { + assertResponseIsClosed(client, successResponse) { + endpoint.getRecommendedStories() + } + } + + @Test + fun `WHEN newInstance is called THEN a new instance configured with the client provided is returned`() { + val result = PocketEndpointRaw.newInstance(client) + + assertSame(client, result.client) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/api/PocketEndpointTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/api/PocketEndpointTest.kt new file mode 100644 index 0000000000..db53750279 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/api/PocketEndpointTest.kt @@ -0,0 +1,97 @@ +/* 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.pocket.stories.api + +import mozilla.components.concept.fetch.Client +import mozilla.components.service.pocket.helpers.PocketTestResources +import mozilla.components.service.pocket.helpers.assertClassVisibility +import mozilla.components.service.pocket.helpers.assertResponseIsFailure +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.whenever +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import kotlin.reflect.KVisibility + +class PocketEndpointTest { + + private lateinit var endpoint: PocketEndpoint + private lateinit var raw: PocketEndpointRaw // we shorten the name to avoid confusion with endpoint. + private lateinit var jsonParser: PocketJSONParser + + private lateinit var client: Client + + @Before + fun setUp() { + raw = mock() + jsonParser = mock() + endpoint = PocketEndpoint(raw, jsonParser) + + client = mock() + } + + @Test + fun `GIVEN a PocketEndpoint THEN its visibility is internal`() { + assertClassVisibility(PocketEndpoint::class, KVisibility.INTERNAL) + } + + @Test + fun `GIVEN an api request for stories WHEN getting a null response THEN a failure is returned`() { + whenever(raw.getRecommendedStories()).thenReturn(null) + whenever(jsonParser.jsonToPocketApiStories(any())).thenThrow( + AssertionError( + "We assume this won't get called so we don't mock it", + ), + ) + + assertResponseIsFailure(endpoint.getRecommendedStories()) + } + + @Test + fun `GIVEN an api request for stories WHEN getting an empty response THEN a failure is returned`() { + whenever(raw.getRecommendedStories()).thenReturn("") + whenever(jsonParser.jsonToPocketApiStories(any())).thenReturn(null) + + assertResponseIsFailure(endpoint.getRecommendedStories()) + } + + @Test + fun `GIVEN an api request for stories WHEN getting a response THEN parse map it through PocketJSONParser`() { + arrayOf( + "", + " ", + "{}", + """{"expectedJSON": 101}""", + ).forEach { expected -> + whenever(raw.getRecommendedStories()).thenReturn(expected) + + endpoint.getRecommendedStories() + + verify(jsonParser, times(1)).jsonToPocketApiStories(expected) + } + } + + @Test + fun `GIVEN an api request for stories WHEN getting a valid response THEN a success with the data is returned`() { + val expected = PocketTestResources.apiExpectedPocketStoriesRecommendations + whenever(raw.getRecommendedStories()).thenReturn("") + whenever(jsonParser.jsonToPocketApiStories(any())).thenReturn(expected) + + val actual = endpoint.getRecommendedStories() + + assertEquals(expected, (actual as? PocketResponse.Success)?.data) + } + + @Test + fun `WHEN newInstance is called THEN a new PocketEndpoint is returned as a wrapper over a configured PocketEndpointRaw`() { + val result = PocketEndpoint.newInstance(client) + + assertSame(client, result.rawEndpoint.client) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/api/PocketJSONParserTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/api/PocketJSONParserTest.kt new file mode 100644 index 0000000000..8deeee794f --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/api/PocketJSONParserTest.kt @@ -0,0 +1,182 @@ +/* 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.pocket.stories.api + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.service.pocket.helpers.PocketTestResources +import mozilla.components.service.pocket.helpers.assertClassVisibility +import mozilla.components.service.pocket.stories.api.PocketJSONParser.Companion.KEY_ARRAY_ITEMS +import org.json.JSONObject +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 kotlin.reflect.KVisibility + +@RunWith(AndroidJUnit4::class) +class PocketJSONParserTest { + + private lateinit var parser: PocketJSONParser + + @Before + fun setUp() { + parser = PocketJSONParser() + } + + @Test + fun `GIVEN a PocketJSONParser THEN its visibility is internal`() { + assertClassVisibility(PocketJSONParser::class, KVisibility.INTERNAL) + } + + @Test + fun `GIVEN PocketJSONParser WHEN parsing valid stories recommendations THEN PocketApiStories are returned`() { + val expectedStories = PocketTestResources.apiExpectedPocketStoriesRecommendations + val pocketJSON = PocketTestResources.pocketEndpointFiveStoriesResponse + val actualStories = parser.jsonToPocketApiStories(pocketJSON) + + assertNotNull(actualStories) + assertEquals(5, actualStories!!.size) + assertEquals(expectedStories, actualStories) + } + + @Test + fun `WHEN parsing stories recommendations with missing titles THEN those entries are dropped`() { + val pocketJSON = PocketTestResources.pocketEndpointFiveStoriesResponse + val expectedStoriesIfMissingTitle = ArrayList(PocketTestResources.apiExpectedPocketStoriesRecommendations) + .apply { removeAt(4) } + val pocketJsonWithMissingTitle = removeJsonFieldFromArrayIndex("title", 4, pocketJSON) + + val result = parser.jsonToPocketApiStories(pocketJsonWithMissingTitle) + + assertEquals(4, result!!.size) + assertEquals(expectedStoriesIfMissingTitle.joinToString(), result.joinToString()) + } + + @Test + fun `WHEN parsing stories recommendations with a null title value THEN those entries are dropped`() { + val pocketJSON = PocketTestResources.pocketEndpointNullTitleStoryBadResponse + val result = parser.jsonToPocketApiStories(pocketJSON) + + assertNull(result) + } + + @Test + fun `WHEN parsing stories recommendations with missing urls THEN those entries are dropped`() { + val pocketJSON = PocketTestResources.pocketEndpointFiveStoriesResponse + val expectedStoriesIfMissingUrl = ArrayList(PocketTestResources.apiExpectedPocketStoriesRecommendations) + .apply { removeAt(3) } + val pocketJsonWithMissingUrl = removeJsonFieldFromArrayIndex("url", 3, pocketJSON) + + val result = parser.jsonToPocketApiStories(pocketJsonWithMissingUrl) + + assertEquals(4, result!!.size) + assertEquals(expectedStoriesIfMissingUrl.joinToString(), result.joinToString()) + } + + @Test + fun `WHEN parsing stories recommendations with a null url THEN those entries are dropped`() { + val pocketJSON = PocketTestResources.pocketEndpointNullUrlStoryBadResponse + val result = parser.jsonToPocketApiStories(pocketJSON) + + assertNull(result) + } + + @Test + fun `WHEN parsing stories recommendations with missing imageUrls THEN those entries are dropped`() { + val pocketJSON = PocketTestResources.pocketEndpointFiveStoriesResponse + val expectedStoriesIfMissingImageUrl = ArrayList(PocketTestResources.apiExpectedPocketStoriesRecommendations) + .apply { removeAt(2) } + val pocketJsonWithMissingImageUrl = removeJsonFieldFromArrayIndex("imageUrl", 2, pocketJSON) + + val result = parser.jsonToPocketApiStories(pocketJsonWithMissingImageUrl) + + assertEquals(4, result!!.size) + assertEquals(expectedStoriesIfMissingImageUrl.joinToString(), result.joinToString()) + } + + @Test + fun `WHEN parsing story recommendations with a null imageUrl THEN those entries are dropped`() { + val pocketJSON = PocketTestResources.pocketEndpointNullImageUrlStoryBadResponse + val result = parser.jsonToPocketApiStories(pocketJSON) + + assertNull(result) + } + + @Test + fun `WHEN parsing stories recommendations with missing publishers THEN those entries are kept but with default values`() { + val pocketJSON = PocketTestResources.pocketEndpointFiveStoriesResponse + val expectedStoriesIfMissingPublishers = PocketTestResources.apiExpectedPocketStoriesRecommendations + .mapIndexed { index, story -> + if (index == 2) { + story.copy(publisher = STRING_NOT_FOUND_DEFAULT_VALUE) + } else { + story + } + } + val pocketJsonWithMissingPublisher = removeJsonFieldFromArrayIndex("publisher", 2, pocketJSON) + + val result = parser.jsonToPocketApiStories(pocketJsonWithMissingPublisher) + + assertEquals(5, result!!.size) + assertEquals(expectedStoriesIfMissingPublishers.joinToString(), result.joinToString()) + } + + @Test + fun `WHEN parsing stories recommendations with missing categories THEN those entries are kept but with default values`() { + val pocketJSON = PocketTestResources.pocketEndpointFiveStoriesResponse + val expectedStoriesIfMissingCategories = PocketTestResources.apiExpectedPocketStoriesRecommendations + .mapIndexed { index, story -> + if (index == 3) { + story.copy(category = STRING_NOT_FOUND_DEFAULT_VALUE) + } else { + story + } + } + val pocketJsonWithMissingCategories = removeJsonFieldFromArrayIndex("category", 3, pocketJSON) + + val result = parser.jsonToPocketApiStories(pocketJsonWithMissingCategories) + + assertEquals(5, result!!.size) + assertEquals(expectedStoriesIfMissingCategories.joinToString(), result.joinToString()) + } + + @Test + fun `WHEN parsing stories recommendations with missing timeToRead THEN those entries are kept but with default values`() { + val pocketJSON = PocketTestResources.pocketEndpointFiveStoriesResponse + val expectedStoriesIfMissingTimeToRead = PocketTestResources.apiExpectedPocketStoriesRecommendations + .mapIndexed { index, story -> + if (index == 4) { + story.copy(timeToRead = INT_NOT_FOUND_DEFAULT_VALUE) + } else { + story + } + } + val pocketJsonWithMissingTimeToRead = removeJsonFieldFromArrayIndex("timeToRead", 4, pocketJSON) + + val result = parser.jsonToPocketApiStories(pocketJsonWithMissingTimeToRead) + + assertEquals(5, result!!.size) + assertEquals(expectedStoriesIfMissingTimeToRead.joinToString(), result.joinToString()) + } + + @Test + fun `WHEN parsing stories recommendations for an empty string THEN null is returned`() { + assertNull(parser.jsonToPocketApiStories("")) + } + + @Test + fun `WHEN parsing stories recommendations for an invalid JSON String THEN null is returned`() { + assertNull(parser.jsonToPocketApiStories("{!!}}")) + } +} + +private fun removeJsonFieldFromArrayIndex(fieldName: String, indexInArray: Int, json: String): String { + val obj = JSONObject(json) + val storiesJson = obj.getJSONArray(KEY_ARRAY_ITEMS) + storiesJson.getJSONObject(indexInArray).remove(fieldName) + return obj.toString() +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/api/PocketResponseTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/api/PocketResponseTest.kt new file mode 100644 index 0000000000..3a77bbec7f --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/api/PocketResponseTest.kt @@ -0,0 +1,56 @@ +/* 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.pocket.stories.api + +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class PocketResponseTest { + @Test + fun `GIVEN a null argument WHEN wrap is called THEN a Failure is returned`() { + assertTrue(PocketResponse.wrap(null) is PocketResponse.Failure) + } + + @Test + fun `GIVEN an empty Collection argument WHEN wrap is called THEN a Failure is returned`() { + assertTrue(PocketResponse.wrap(emptyList<Any>()) is PocketResponse.Failure<*>) + } + + @Test + fun `GIVEN a not empty Collection argument WHEN wrap is called THEN a Success wrapping that argument is returned`() { + val argument = listOf(1) + + val result = PocketResponse.wrap(argument) + + assertTrue(result is PocketResponse.Success) + assertSame(argument, (result as PocketResponse.Success).data) + } + + @Test + fun `GIVEN an empty String argument WHEN wrap is called THEN a Failure is returned`() { + assertTrue(PocketResponse.wrap("") is PocketResponse.Failure<String>) + } + + @Test + fun `GIVEN a not empty String argument WHEN wrap is called THEN a Success wrapping that argument is returned`() { + val argument = "not empty" + + val result = PocketResponse.wrap(argument) + + assertTrue(result is PocketResponse.Success) + assertSame(argument, (result as PocketResponse.Success).data) + } + + @Test + fun `GIVEN a random argument WHEN wrap is called THEN a Success wrapping that argument is returned`() { + val argument = 42 + + val result = PocketResponse.wrap(argument) + + assertTrue(result is PocketResponse.Success) + assertSame(argument, (result as PocketResponse.Success).data) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/db/PocketRecommendationsDaoTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/db/PocketRecommendationsDaoTest.kt new file mode 100644 index 0000000000..e9f2b8208d --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/db/PocketRecommendationsDaoTest.kt @@ -0,0 +1,387 @@ +/* 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.pocket.stories.db + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.service.pocket.helpers.PocketTestResources +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class PocketRecommendationsDaoTest { + private val context: Context + get() = ApplicationProvider.getApplicationContext() + private lateinit var database: PocketRecommendationsDatabase + private lateinit var dao: PocketRecommendationsDao + private lateinit var executor: ExecutorService + + @get:Rule + var instantTaskExecutorRule = InstantTaskExecutorRule() + + @Before + fun setUp() { + executor = Executors.newSingleThreadExecutor() + database = Room + .inMemoryDatabaseBuilder(context, PocketRecommendationsDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = database.pocketRecommendationsDao() + } + + @After + fun tearDown() { + database.close() + executor.shutdown() + } + + @Test + fun `GIVEN an empty table WHEN a story is inserted and then queried THEN return the same story`() = runTest { + val story = PocketTestResources.dbExpectedPocketStory + + dao.insertPocketStories(listOf(story)) + val result = dao.getPocketStories() + + assertEquals(listOf(story), result) + } + + @Test + fun `GIVEN a story already persisted WHEN another story with identical url is tried to be inserted THEN add that to the table`() = runTest { + val story = PocketTestResources.dbExpectedPocketStory + val newStory = story.copy( + url = "updated" + story.url, + ) + dao.insertPocketStories(listOf(story)) + + dao.insertPocketStories(listOf(newStory)) + val result = dao.getPocketStories() + + assertEquals(listOf(story, newStory), result) + } + + @Test + fun `GIVEN a story with the same url exists WHEN another story with updated title is tried to be inserted THEN don't update the table`() = runTest { + val story = PocketTestResources.dbExpectedPocketStory + val updatedStory = story.copy( + title = "updated" + story.title, + ) + dao.insertPocketStories(listOf(story)) + + dao.insertPocketStories(listOf(updatedStory)) + val result = dao.getPocketStories() + + assertTrue(result.size == 1) + assertEquals(story, result[0]) + } + + @Test + fun `GIVEN a story with the same url exists WHEN another story with updated imageUrl is tried to be inserted THEN don't update the table`() = runTest { + val story = PocketTestResources.dbExpectedPocketStory + val updatedStory = story.copy( + imageUrl = "updated" + story.imageUrl, + ) + dao.insertPocketStories(listOf(story)) + + dao.insertPocketStories(listOf(updatedStory)) + val result = dao.getPocketStories() + + assertEquals(listOf(story), result) + } + + @Test + fun `GIVEN a story with the same url exists WHEN another story with updated publisher is tried to be inserted THEN don't update the table`() = runTest { + val story = PocketTestResources.dbExpectedPocketStory + val updatedStory = story.copy( + publisher = "updated" + story.publisher, + ) + dao.insertPocketStories(listOf(story)) + + dao.insertPocketStories(listOf(updatedStory)) + val result = dao.getPocketStories() + + assertEquals(listOf(story), result) + } + + @Test + fun `GIVEN a story with the same url exists WHEN another story with updated category is tried to be inserted THEN don't update the table`() = runTest { + val story = PocketTestResources.dbExpectedPocketStory + val updatedStory = story.copy( + category = "updated" + story.category, + ) + dao.insertPocketStories(listOf(story)) + + dao.insertPocketStories(listOf(updatedStory)) + val result = dao.getPocketStories() + + assertEquals(listOf(story), result) + } + + @Test + fun `GIVEN a story with the same url exists WHEN another story with updated timeToRead is tried to be inserted THEN don't update the table`() = runTest { + val story = PocketTestResources.dbExpectedPocketStory + val updatedStory = story.copy( + timesShown = story.timesShown * 2, + ) + dao.insertPocketStories(listOf(story)) + + dao.insertPocketStories(listOf(updatedStory)) + val result = dao.getPocketStories() + + assertEquals(listOf(story), result) + } + + @Test + fun `GIVEN a story with the same url exists WHEN another story with updated timesShown is tried to be inserted THEN don't update the table`() = runTest { + val story = PocketTestResources.dbExpectedPocketStory + val updatedStory = story.copy( + timesShown = story.timesShown * 2, + ) + dao.insertPocketStories(listOf(story)) + + dao.insertPocketStories(listOf(updatedStory)) + val result = dao.getPocketStories() + + assertEquals(listOf(story), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to delete some THEN remove them from the table`() = runTest { + val story1 = PocketTestResources.dbExpectedPocketStory + val story2 = PocketTestResources.dbExpectedPocketStory.copy(url = story1.url + "2") + val story3 = PocketTestResources.dbExpectedPocketStory.copy(url = story1.url + "3") + val story4 = PocketTestResources.dbExpectedPocketStory.copy(url = story1.url + "4") + dao.insertPocketStories(listOf(story1, story2, story3, story4)) + + dao.delete(listOf(story2, story4)) + val result = dao.getPocketStories() + + assertEquals(listOf(story1, story3), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to delete one not present in the table THEN don't update the table`() = runTest { + val story1 = PocketTestResources.dbExpectedPocketStory + val story2 = PocketTestResources.dbExpectedPocketStory.copy(url = story1.url + "2") + val story3 = PocketTestResources.dbExpectedPocketStory.copy(url = story1.url + "3") + val story4 = PocketTestResources.dbExpectedPocketStory.copy(url = story1.url + "4") + dao.insertPocketStories(listOf(story1, story2, story3)) + + dao.delete(listOf(story4)) + val result = dao.getPocketStories() + + assertEquals(listOf(story1, story2, story3), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to update timesShown for one THEN update only that story`() = runTest { + val story1 = PocketTestResources.dbExpectedPocketStory + val story2 = PocketTestResources.dbExpectedPocketStory.copy( + url = story1.url + "2", + timesShown = story1.timesShown * 2, + ) + val story3 = PocketTestResources.dbExpectedPocketStory.copy( + url = story1.url + "3", + timesShown = story1.timesShown * 3, + ) + val story4 = PocketTestResources.dbExpectedPocketStory.copy( + url = story1.url + "4", + timesShown = story1.timesShown * 4, + ) + val updatedStory2 = PocketLocalStoryTimesShown(story2.url, 222) + val updatedStory4 = PocketLocalStoryTimesShown(story4.url, 444) + dao.insertPocketStories(listOf(story1, story2, story3, story4)) + + dao.updateTimesShown(listOf(updatedStory2, updatedStory4)) + val result = dao.getPocketStories() + + assertEquals( + listOf( + story1, + story2.copy(timesShown = 222), + story3, + story4.copy(timesShown = 444), + ), + result, + ) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to update timesShown for one not present in the table THEN don't update the table`() = runTest { + val story1 = PocketTestResources.dbExpectedPocketStory + val story2 = PocketTestResources.dbExpectedPocketStory.copy( + url = story1.url + "2", + timesShown = story1.timesShown * 2, + ) + val story3 = PocketTestResources.dbExpectedPocketStory.copy( + url = story1.url + "3", + timesShown = story1.timesShown * 3, + ) + val story4 = PocketTestResources.dbExpectedPocketStory.copy( + url = story1.url + "4", + timesShown = story1.timesShown * 4, + ) + val otherStoryUpdateDetails = PocketLocalStoryTimesShown("differentUrl", 111) + dao.insertPocketStories(listOf(story1, story2, story3, story4)) + + dao.updateTimesShown(listOf(otherStoryUpdateDetails)) + val result = dao.getPocketStories() + + assertEquals(listOf(story1, story2, story3, story4), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN remove from table all stories not found in the new list`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketStory + val story2 = PocketTestResources.dbExpectedPocketStory.copy(url = story1.url + "2") + val story3 = PocketTestResources.dbExpectedPocketStory.copy(url = story1.url + "3") + val story4 = PocketTestResources.dbExpectedPocketStory.copy(url = story1.url + "4") + dao.insertPocketStories(listOf(story1, story2, story3, story4)) + + dao.cleanOldAndInsertNewPocketStories(listOf(story2, story4)) + val result = dao.getPocketStories() + + assertEquals(listOf(story2, story4), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN update stories with new urls`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketStory + val story2 = PocketTestResources.dbExpectedPocketStory.copy(url = story1.url + "2") + val updatedStory1 = story1.copy( + url = "updated" + story1.url, + ) + dao.insertPocketStories(listOf(story1, story2)) + + dao.cleanOldAndInsertNewPocketStories(listOf(updatedStory1, story2)) + val result = dao.getPocketStories() + + // Order gets reversed because the original story is replaced and another one is added. + assertEquals(listOf(story2, updatedStory1), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN update stories with new image urls`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketStory + val story2 = PocketTestResources.dbExpectedPocketStory.copy(url = story1.url + "2") + val updatedStory2 = story2.copy( + imageUrl = "updated" + story2.url, + ) + dao.insertPocketStories(listOf(story1, story2)) + + dao.cleanOldAndInsertNewPocketStories(listOf(story1, updatedStory2)) + val result = dao.getPocketStories() + + assertEquals(listOf(story1, updatedStory2), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN don't update story if only title changed`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketStory + val story2 = PocketTestResources.dbExpectedPocketStory.copy(url = story1.url + "2") + val updatedStory1 = story1.copy( + title = "updated" + story1.title, + ) + dao.insertPocketStories(listOf(story1, story2)) + + dao.cleanOldAndInsertNewPocketStories(listOf(updatedStory1, story2)) + val result = dao.getPocketStories() + + assertEquals(listOf(story1, story2), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN don't update story if only publisher changed`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketStory + val story2 = PocketTestResources.dbExpectedPocketStory.copy(url = story1.url + "2") + val updatedStory2 = story2.copy( + publisher = "updated" + story2.publisher, + ) + dao.insertPocketStories(listOf(story1, story2)) + + dao.cleanOldAndInsertNewPocketStories(listOf(story1, updatedStory2)) + val result = dao.getPocketStories() + + assertEquals(listOf(story1, story2), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN don't update story if only category changed`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketStory + val story2 = PocketTestResources.dbExpectedPocketStory.copy(url = story1.url + "2") + val updatedStory1 = story1.copy( + category = "updated" + story1.category, + ) + dao.insertPocketStories(listOf(story1, story2)) + + dao.cleanOldAndInsertNewPocketStories(listOf(updatedStory1, story2)) + val result = dao.getPocketStories() + + assertEquals(listOf(story1, story2), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN don't update story if only timeToRead changed`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketStory + val story2 = PocketTestResources.dbExpectedPocketStory.copy(url = story1.url + "2") + val updatedStory1 = story1.copy( + timeToRead = story1.timeToRead * 2, + ) + dao.insertPocketStories(listOf(story1, story2)) + + dao.cleanOldAndInsertNewPocketStories(listOf(updatedStory1, story2)) + val result = dao.getPocketStories() + + assertEquals(listOf(story1, story2), result) + } + + @Test + fun `GIVEN stories already persisted WHEN asked to clean and insert new ones THEN don't update story if only timesShown changed`() = runTest { + setupDatabseForTransactions() + val story1 = PocketTestResources.dbExpectedPocketStory + val story2 = PocketTestResources.dbExpectedPocketStory.copy(url = story1.url + "2") + val updatedStory2 = story2.copy( + timesShown = story2.timesShown * 2, + ) + dao.insertPocketStories(listOf(story1, story2)) + + dao.cleanOldAndInsertNewPocketStories(listOf(story1, updatedStory2)) + val result = dao.getPocketStories() + + assertEquals(listOf(story1, story2), result) + } + + /** + * Sets an executor to be used for database transactions. + * Needs to be used along with "runTest" to ensure waiting for transactions to finish but not hang tests. + */ + private fun setupDatabseForTransactions() { + database = Room + .inMemoryDatabaseBuilder(context, PocketRecommendationsDatabase::class.java) + .setTransactionExecutor(executor) + .allowMainThreadQueries() + .build() + dao = database.pocketRecommendationsDao() + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/db/PocketStoryEntityTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/db/PocketStoryEntityTest.kt new file mode 100644 index 0000000000..66c0f66c30 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/stories/db/PocketStoryEntityTest.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.pocket.stories.db + +import mozilla.components.service.pocket.helpers.assertClassVisibility +import org.junit.Test +import kotlin.reflect.KVisibility + +class PocketStoryEntityTest { + // This is the data type persisted locally. No need to be public + @Test + fun `GIVEN a PocketLocalStory THEN its visibility is internal`() { + assertClassVisibility(PocketStoryEntity::class, KVisibility.INTERNAL) + } + + // This is a data type only used in local updates. No need to be public + @Test + fun `GIVEN a PocketLocalStoryTimesShown THEN its visibility is internal`() { + assertClassVisibility(PocketLocalStoryTimesShown::class, KVisibility.INTERNAL) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/update/DeleteSpocsProfileWorkerTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/update/DeleteSpocsProfileWorkerTest.kt new file mode 100644 index 0000000000..ab66365577 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/update/DeleteSpocsProfileWorkerTest.kt @@ -0,0 +1,65 @@ +/* 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.pocket.update + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.ListenableWorker.Result +import androidx.work.await +import androidx.work.testing.TestListenableWorkerBuilder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.service.pocket.GlobalDependencyProvider +import mozilla.components.service.pocket.helpers.assertClassVisibility +import mozilla.components.service.pocket.spocs.SpocsUseCases +import mozilla.components.service.pocket.spocs.SpocsUseCases.DeleteProfile +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import kotlin.reflect.KVisibility.INTERNAL + +@ExperimentalCoroutinesApi // for runTestOnMain +@RunWith(AndroidJUnit4::class) +class DeleteSpocsProfileWorkerTest { + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + @Test + fun `GIVEN a DeleteSpocsProfileWorker THEN its visibility is internal`() { + assertClassVisibility(RefreshSpocsWorker::class, INTERNAL) + } + + @Test + fun `GIVEN a DeleteSpocsProfileWorker WHEN profile deletion is successful THEN return success`() = runTestOnMain { + val useCases: SpocsUseCases = mock() + val deleteProfileUseCase: DeleteProfile = mock() + doReturn(true).`when`(deleteProfileUseCase).invoke() + doReturn(deleteProfileUseCase).`when`(useCases).deleteProfile + GlobalDependencyProvider.SponsoredStories.initialize(useCases) + val worker = TestListenableWorkerBuilder<DeleteSpocsProfileWorker>(testContext).build() + + val result = worker.startWork().await() + + assertEquals(Result.success(), result) + } + + @Test + fun `GIVEN a DeleteSpocsProfileWorker WHEN profile deletion fails THEN work should be retried`() = runTestOnMain { + val useCases: SpocsUseCases = mock() + val deleteProfileUseCase: DeleteProfile = mock() + doReturn(false).`when`(deleteProfileUseCase).invoke() + doReturn(deleteProfileUseCase).`when`(useCases).deleteProfile + GlobalDependencyProvider.SponsoredStories.initialize(useCases) + val worker = TestListenableWorkerBuilder<DeleteSpocsProfileWorker>(testContext).build() + + val result = worker.startWork().await() + + assertEquals(Result.retry(), result) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/update/PocketStoriesRefreshSchedulerTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/update/PocketStoriesRefreshSchedulerTest.kt new file mode 100644 index 0000000000..121b8a5145 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/update/PocketStoriesRefreshSchedulerTest.kt @@ -0,0 +1,103 @@ +/* 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.pocket.update + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient +import mozilla.components.service.pocket.PocketStoriesConfig +import mozilla.components.service.pocket.helpers.assertClassVisibility +import mozilla.components.service.pocket.update.RefreshPocketWorker.Companion.REFRESH_WORK_TAG +import mozilla.components.support.base.worker.Frequency +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import java.util.concurrent.TimeUnit +import kotlin.reflect.KVisibility + +@RunWith(AndroidJUnit4::class) +class PocketStoriesRefreshSchedulerTest { + @Test + fun `GIVEN a PocketStoriesRefreshScheduler THEN its visibility is internal`() { + assertClassVisibility(PocketStoriesRefreshScheduler::class, KVisibility.INTERNAL) + } + + @Test + fun `GIVEN a PocketStoriesRefreshScheduler WHEN schedulePeriodicRefreshes THEN a RefreshPocketWorker is created and enqueued`() { + val client: HttpURLConnectionClient = mock() + val scheduler = spy( + PocketStoriesRefreshScheduler( + PocketStoriesConfig( + client, + Frequency(1, TimeUnit.HOURS), + ), + ), + ) + val workManager = mock<WorkManager>() + val worker = mock<PeriodicWorkRequest>() + doReturn(workManager).`when`(scheduler).getWorkManager(any()) + doReturn(worker).`when`(scheduler).createPeriodicWorkerRequest(any()) + + scheduler.schedulePeriodicRefreshes(testContext) + + verify(workManager).enqueueUniquePeriodicWork(REFRESH_WORK_TAG, ExistingPeriodicWorkPolicy.KEEP, worker) + } + + @Test + fun `GIVEN a PocketStoriesRefreshScheduler WHEN stopPeriodicRefreshes THEN it should cancel all unfinished work`() { + val scheduler = spy(PocketStoriesRefreshScheduler(mock())) + val workManager = mock<WorkManager>() + doReturn(workManager).`when`(scheduler).getWorkManager(any()) + + scheduler.stopPeriodicRefreshes(testContext) + + verify(workManager).cancelAllWorkByTag(REFRESH_WORK_TAG) + verify(workManager, Mockito.never()).cancelAllWork() + } + + @Test + fun `GIVEN a PocketStoriesRefreshScheduler WHEN createPeriodicWorkerRequest THEN a properly configured PeriodicWorkRequest is returned`() { + val scheduler = spy(PocketStoriesRefreshScheduler(mock())) + + val result = scheduler.createPeriodicWorkerRequest( + Frequency(1, TimeUnit.HOURS), + ) + + verify(scheduler).getWorkerConstrains() + assertTrue(result.workSpec.intervalDuration == TimeUnit.HOURS.toMillis(1)) + assertFalse(result.workSpec.constraints.requiresBatteryNotLow()) + assertFalse(result.workSpec.constraints.requiresCharging()) + assertFalse(result.workSpec.constraints.hasContentUriTriggers()) + assertFalse(result.workSpec.constraints.requiresStorageNotLow()) + assertFalse(result.workSpec.constraints.requiresDeviceIdle()) + assertTrue(result.workSpec.constraints.requiredNetworkType == NetworkType.CONNECTED) + assertTrue(result.tags.contains(REFRESH_WORK_TAG)) + } + + @Test + fun `GIVEN PocketStoriesRefreshScheduler THEN Worker constraints should be to have Internet`() { + val scheduler = PocketStoriesRefreshScheduler(mock()) + + val result = scheduler.getWorkerConstrains() + + assertFalse(result.requiresBatteryNotLow()) + assertFalse(result.requiresCharging()) + assertFalse(result.hasContentUriTriggers()) + assertFalse(result.requiresStorageNotLow()) + assertFalse(result.requiresDeviceIdle()) + assertTrue(result.requiredNetworkType == NetworkType.CONNECTED) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/update/RefreshPocketWorkerTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/update/RefreshPocketWorkerTest.kt new file mode 100644 index 0000000000..3691fb76a5 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/update/RefreshPocketWorkerTest.kt @@ -0,0 +1,64 @@ +/* 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.pocket.update + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.ListenableWorker +import androidx.work.await +import androidx.work.testing.TestListenableWorkerBuilder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.service.pocket.GlobalDependencyProvider +import mozilla.components.service.pocket.helpers.assertClassVisibility +import mozilla.components.service.pocket.stories.PocketStoriesUseCases +import mozilla.components.service.pocket.stories.PocketStoriesUseCases.RefreshPocketStories +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import kotlin.reflect.KVisibility + +@ExperimentalCoroutinesApi // for runTestOnMain +@RunWith(AndroidJUnit4::class) +class RefreshPocketWorkerTest { + + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + @Test + fun `GIVEN a RefreshPocketWorker THEN its visibility is internal`() { + assertClassVisibility(RefreshPocketWorker::class, KVisibility.INTERNAL) + } + + @Test + fun `GIVEN a RefreshPocketWorker WHEN stories are refreshed successfully THEN return success`() = runTestOnMain { + val useCases: PocketStoriesUseCases = mock() + val refreshStoriesUseCase: RefreshPocketStories = mock() + doReturn(true).`when`(refreshStoriesUseCase).invoke() + doReturn(refreshStoriesUseCase).`when`(useCases).refreshStories + GlobalDependencyProvider.RecommendedStories.initialize(useCases) + val worker = TestListenableWorkerBuilder<RefreshPocketWorker>(testContext).build() + + val result = worker.startWork().await() + assertEquals(ListenableWorker.Result.success(), result) + } + + @Test + fun `GIVEN a RefreshPocketWorker WHEN stories are could not be refreshed THEN work should be retried`() = runTestOnMain { + val useCases: PocketStoriesUseCases = mock() + val refreshStoriesUseCase: RefreshPocketStories = mock() + doReturn(false).`when`(refreshStoriesUseCase).invoke() + doReturn(refreshStoriesUseCase).`when`(useCases).refreshStories + GlobalDependencyProvider.RecommendedStories.initialize(useCases) + val worker = TestListenableWorkerBuilder<RefreshPocketWorker>(testContext).build() + + val result = worker.startWork().await() + assertEquals(ListenableWorker.Result.retry(), result) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/update/RefreshSpocsWorkerTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/update/RefreshSpocsWorkerTest.kt new file mode 100644 index 0000000000..89ea044add --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/update/RefreshSpocsWorkerTest.kt @@ -0,0 +1,64 @@ +/* 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.pocket.update + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.ListenableWorker +import androidx.work.await +import androidx.work.testing.TestListenableWorkerBuilder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.service.pocket.GlobalDependencyProvider +import mozilla.components.service.pocket.helpers.assertClassVisibility +import mozilla.components.service.pocket.spocs.SpocsUseCases +import mozilla.components.service.pocket.spocs.SpocsUseCases.RefreshSponsoredStories +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import kotlin.reflect.KVisibility.INTERNAL + +@ExperimentalCoroutinesApi // for runTestOnMain +@RunWith(AndroidJUnit4::class) +class RefreshSpocsWorkerTest { + + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + @Test + fun `GIVEN a RefreshSpocsWorker THEN its visibility is internal`() { + assertClassVisibility(RefreshSpocsWorker::class, INTERNAL) + } + + @Test + fun `GIVEN a RefreshSpocsWorker WHEN stories are refreshed successfully THEN return success`() = runTestOnMain { + val useCases: SpocsUseCases = mock() + val refreshStoriesUseCase: RefreshSponsoredStories = mock() + doReturn(true).`when`(refreshStoriesUseCase).invoke() + doReturn(refreshStoriesUseCase).`when`(useCases).refreshStories + GlobalDependencyProvider.SponsoredStories.initialize(useCases) + val worker = TestListenableWorkerBuilder<RefreshSpocsWorker>(testContext).build() + + val result = worker.startWork().await() + assertEquals(ListenableWorker.Result.success(), result) + } + + @Test + fun `GIVEN a RefreshSpocsWorker WHEN stories are could not be refreshed THEN work should be retried`() = runTestOnMain { + val useCases: SpocsUseCases = mock() + val refreshStoriesUseCase: RefreshSponsoredStories = mock() + doReturn(false).`when`(refreshStoriesUseCase).invoke() + doReturn(refreshStoriesUseCase).`when`(useCases).refreshStories + GlobalDependencyProvider.SponsoredStories.initialize(useCases) + val worker = TestListenableWorkerBuilder<RefreshSpocsWorker>(testContext).build() + + val result = worker.startWork().await() + assertEquals(ListenableWorker.Result.retry(), result) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/update/SpocsRefreshSchedulerTest.kt b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/update/SpocsRefreshSchedulerTest.kt new file mode 100644 index 0000000000..70194c2939 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/java/mozilla/components/service/pocket/update/SpocsRefreshSchedulerTest.kt @@ -0,0 +1,161 @@ +/* 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.pocket.update + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.BackoffPolicy +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient +import mozilla.components.service.pocket.PocketStoriesConfig +import mozilla.components.service.pocket.helpers.assertClassVisibility +import mozilla.components.service.pocket.update.DeleteSpocsProfileWorker.Companion.DELETE_SPOCS_PROFILE_WORK_TAG +import mozilla.components.service.pocket.update.RefreshSpocsWorker.Companion.REFRESH_SPOCS_WORK_TAG +import mozilla.components.support.base.worker.Frequency +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import java.util.concurrent.TimeUnit +import kotlin.reflect.KVisibility + +@RunWith(AndroidJUnit4::class) +class SpocsRefreshSchedulerTest { + @Test + fun `GIVEN a spocs refresh scheduler THEN its visibility is internal`() { + assertClassVisibility(SpocsRefreshScheduler::class, KVisibility.INTERNAL) + } + + @Test + fun `GIVEN a spocs refresh scheduler WHEN scheduling stories refresh THEN a RefreshPocketWorker is created and enqueued`() { + val client: HttpURLConnectionClient = mock() + val scheduler = spy( + SpocsRefreshScheduler( + PocketStoriesConfig( + client, + Frequency(1, TimeUnit.HOURS), + ), + ), + ) + val workManager = mock<WorkManager>() + val worker = mock<PeriodicWorkRequest>() + doReturn(workManager).`when`(scheduler).getWorkManager(any()) + doReturn(worker).`when`(scheduler).createPeriodicRefreshWorkerRequest(any()) + + scheduler.schedulePeriodicRefreshes(testContext) + + verify(workManager).enqueueUniquePeriodicWork(REFRESH_SPOCS_WORK_TAG, ExistingPeriodicWorkPolicy.KEEP, worker) + } + + @Test + fun `GIVEN a spocs refresh scheduler WHEN stopping stories refresh THEN it should cancel all unfinished work`() { + val scheduler = spy(SpocsRefreshScheduler(mock())) + val workManager = mock<WorkManager>() + doReturn(workManager).`when`(scheduler).getWorkManager(any()) + + scheduler.stopPeriodicRefreshes(testContext) + + verify(workManager).cancelAllWorkByTag(REFRESH_SPOCS_WORK_TAG) + verify(workManager, Mockito.never()).cancelAllWork() + } + + @Test + fun `GIVEN a spocs refresh scheduler WHEN scheduling profile deletion THEN a RefreshPocketWorker is created and enqueued`() { + val client: HttpURLConnectionClient = mock() + val scheduler = spy( + SpocsRefreshScheduler( + PocketStoriesConfig( + client, + Frequency(1, TimeUnit.HOURS), + ), + ), + ) + val workManager = mock<WorkManager>() + val worker = mock<OneTimeWorkRequest>() + doReturn(workManager).`when`(scheduler).getWorkManager(any()) + doReturn(worker).`when`(scheduler).createOneTimeProfileDeletionWorkerRequest() + + scheduler.scheduleProfileDeletion(testContext) + + verify(workManager).enqueueUniqueWork(DELETE_SPOCS_PROFILE_WORK_TAG, ExistingWorkPolicy.KEEP, worker) + } + + @Test + fun `GIVEN a spocs refresh scheduler WHEN cancelling profile deletion THEN it should cancel all unfinished work`() { + val scheduler = spy(SpocsRefreshScheduler(mock())) + val workManager = mock<WorkManager>() + doReturn(workManager).`when`(scheduler).getWorkManager(any()) + + scheduler.stopProfileDeletion(testContext) + + verify(workManager).cancelAllWorkByTag(DELETE_SPOCS_PROFILE_WORK_TAG) + verify(workManager, never()).cancelAllWork() + } + + @Test + fun `GIVEN a spocs refresh scheduler WHEN creating a periodic worker THEN a properly configured PeriodicWorkRequest is returned`() { + val scheduler = spy(SpocsRefreshScheduler(mock())) + + val result = scheduler.createPeriodicRefreshWorkerRequest( + Frequency(1, TimeUnit.HOURS), + ) + + verify(scheduler).getWorkerConstrains() + assertTrue(result.workSpec.intervalDuration == TimeUnit.HOURS.toMillis(1)) + assertFalse(result.workSpec.constraints.requiresBatteryNotLow()) + assertFalse(result.workSpec.constraints.requiresCharging()) + assertFalse(result.workSpec.constraints.hasContentUriTriggers()) + assertFalse(result.workSpec.constraints.requiresStorageNotLow()) + assertFalse(result.workSpec.constraints.requiresDeviceIdle()) + assertTrue(result.workSpec.constraints.requiredNetworkType == NetworkType.CONNECTED) + assertTrue(result.tags.contains(REFRESH_SPOCS_WORK_TAG)) + } + + @Test + fun `GIVEN a spocs refresh scheduler WHEN creating a one time worker THEN a properly configured OneTimeWorkRequest is returned`() { + val scheduler = spy(SpocsRefreshScheduler(mock())) + + val result = scheduler.createOneTimeProfileDeletionWorkerRequest() + + verify(scheduler).getWorkerConstrains() + assertEquals(0, result.workSpec.intervalDuration) + assertEquals(0, result.workSpec.initialDelay) + assertEquals(BackoffPolicy.EXPONENTIAL, result.workSpec.backoffPolicy) + assertFalse(result.workSpec.constraints.requiresBatteryNotLow()) + assertFalse(result.workSpec.constraints.requiresCharging()) + assertFalse(result.workSpec.constraints.hasContentUriTriggers()) + assertFalse(result.workSpec.constraints.requiresStorageNotLow()) + assertFalse(result.workSpec.constraints.requiresDeviceIdle()) + assertTrue(result.workSpec.constraints.requiredNetworkType == NetworkType.CONNECTED) + assertTrue(result.tags.contains(DELETE_SPOCS_PROFILE_WORK_TAG)) + } + + @Test + fun `GIVEN a spocs refresh scheduler THEN Worker constraints should be to have Internet`() { + val scheduler = SpocsRefreshScheduler(mock()) + + val result = scheduler.getWorkerConstrains() + + assertFalse(result.requiresBatteryNotLow()) + assertFalse(result.requiresCharging()) + assertFalse(result.hasContentUriTriggers()) + assertFalse(result.requiresStorageNotLow()) + assertFalse(result.requiresDeviceIdle()) + assertTrue(result.requiredNetworkType == NetworkType.CONNECTED) + } +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/service/pocket/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..cf1c399ea8 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/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/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/sponsored_stories_response.json b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/sponsored_stories_response.json new file mode 100644 index 0000000000..45ca1e5b63 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/sponsored_stories_response.json @@ -0,0 +1,98 @@ +{ + "feature_flags": { + "spoc_v2": true, + "collections": false + }, + "spocs": [ + { + "id": 193815086, + "flight_id": 191739319, + "campaign_id": 1315172, + "title": "Eating Keto Has Never Been So Easy With Green Chef", + "url": "https://i.geistm.com/l/GC_7ReasonsKetoV2_Journiest?bcid=601c567ac5b18a0414cce1d4&bhid=624f3ea9adad7604086ac6b3&utm_content=PKT_A_7ReasonsKetoV2_Journiest_40702022_RawMeatballUGC_130Off_601c567ac5b18a0414cce1d4_624f3ea9adad7604086ac6b3&tv=su4&ct=NAT-PK-PROS-130OFF5WEEK-037&utm_medium=DB&utm_source=pocket~geistm&utm_campaign=PKT_A_7ReasonsKetoV2_Journiest_40702022_RawMeatballUGC_130Off", + "domain": "journiest.com", + "excerpt": "Get Green Chef's Special Spring Offer: ${'$'}130 off plus free shipping.", + "priority": 3, + "raw_image_src": "https://s.zkcdn.net/Advertisers/a3644de3c18948ffbd9aa43e8f9c7bf0.png", + "image_src": "https://img-getpocket.cdn.mozilla.net/direct?url=realUrl.png&resize=w618-h310", + "shim": { + "click": "193815086ClickShim", + "impression": "193815086ImpressionShim", + "delete": "193815086DeleteShim", + "save": "193815086SaveShim" + }, + "caps": { + "lifetime": 50, + "campaign": { + "count": 10, + "period": 86400 + }, + "flight": { + "count": 10, + "period": 86400 + } + }, + "sponsor": "Green Chef" + }, + { + "id": 177986195, + "flight_id": 191739667, + "campaign_id": 63548984, + "title": "This Leading Cash Back Card Is a Slam Dunk if You Want a One-Card Wallet", + "url": "https://www.fool.com/the-ascent/credit-cards/landing/discover-it-cash-back-review-v2-csr/?utm_site=theascent&utm_campaign=ta-cc-co-pocket-discb-04012022-5-na-firefox&utm_medium=cpc&utm_source=pocket", + "domain": "fool.com", + "excerpt": "Make 2022 your year for a one-card wallet.", + "priority": 2, + "raw_image_src": "https://s.zkcdn.net/Advertisers/359f56a5423c4926ab3aa148e448d839.webp", + "image_src": "https://img-getpocket.cdn.mozilla.net/direct?url=https%3A//s.zkcdn.net/Advertisers/359f56a5423c4926ab3aa148e448d839.webp&resize=w618-h310", + "shim": { + "click": "177986195ClickShim", + "impression": "177986195ImpressionShim", + "delete": "177986195DeleteShim", + "save": "177986195SaveShim" + }, + "caps": { + "lifetime": 50, + "campaign": { + "count": 10, + "period": 86400 + }, + "flight": { + "count": 10, + "period": 86400 + } + }, + "sponsor": "The Ascent" + }, + { + "id": 192560056, + "flight_id": 189212196, + "campaign_id": 65544139, + "title": "The Incredible Lawn Hack That Can Make Your Neighbors Green With Envy Over Your Lawn", + "url": "https://go.lawnbuddy.org/zf/50/7673?campaign=SUN_Pocket2022&creative=SUN_LawnCompare4-TheIncredibleLawnHackThatCanMakeYourNeighborsGreenWithEnvyOverYourLawn-WithoutSpendingAFortuneOnNewGrassAndWithoutBreakingASweat-20220420", + "domain": "go.lawnbuddy.org", + "excerpt": "Without spending a fortune on new grass and without breaking a sweat.", + "priority": 1, + "raw_image_src": "https://s.zkcdn.net/Advertisers/ce16302e184342cda0619c08b7604c9c.jpg", + "image_src": "https://img-getpocket.cdn.mozilla.net/direct?url=https%3A//s.zkcdn.net/Advertisers/ce16302e184342cda0619c08b7604c9c.jpg&resize=w618-h310", + "shim": { + "click": "192560056ClickShim", + "impression": "192560056ImpressionShim", + "delete": "192560056DeleteShim", + "save": "192560056SaveShim" + }, + "caps": { + "lifetime": 50, + "campaign": { + "count": 10, + "period": 86400 + }, + "flight": { + "count": 10, + "period": 86400 + } + }, + "sponsor": "Sunday" + } + ] +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/stories_recommendations_response.json b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/stories_recommendations_response.json new file mode 100644 index 0000000000..da2b9a2953 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/stories_recommendations_response.json @@ -0,0 +1,44 @@ +{ + "recommendations": [ + { + "category": "general", + "url": "https://getpocket.com/explore/item/how-to-remember-anything-you-really-want-to-remember-backed-by-science", + "title": "How to Remember Anything You Really Want to Remember, Backed by Science", + "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpocket-image-cache.com%2F1200x%2Ffilters%3Aformat(jpg)%3Aextract_focal()%2Fhttps%253A%252F%252Fwww.incimages.com%252Fuploaded_files%252Fimage%252F1920x1080%252Fgetty-862457080_394628.jpg", + "publisher": "Pocket", + "timeToRead": 3 + }, + { + "category": "general", + "url": "https://www.thecut.com/article/i-dont-want-to-be-like-a-family-with-my-co-workers.html", + "title": "‘I Don’t Want to Be Like a Family With My Co-Workers’", + "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpyxis.nymag.com%2Fv1%2Fimgs%2Fac8%2Fd22%2F315cd0cf1e3a43edfe0e0548f2edbcb1a1-ask-a-boss.1x.rsocial.w1200.jpg", + "publisher": "The Cut", + "timeToRead": 5 + }, + { + "category": "general", + "url": "https://www.newyorker.com/news/q-and-a/how-america-failed-in-afghanistan", + "title": "How America Failed in Afghanistan", + "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fmedia.newyorker.com%2Fphotos%2F6119484157b611aec9c99b43%2F16%3A9%2Fw_1280%2Cc_limit%2FChotiner-Afghanistan01.jpg", + "publisher": "The New Yorker", + "timeToRead": 14 + }, + { + "category": "general", + "url": "https://www.technologyreview.com/2021/08/15/1031804/digital-beauty-filters-photoshop-photo-editing-colorism-racism/", + "title": "How digital beauty filters perpetuate colorism", + "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fwp.technologyreview.com%2Fwp-content%2Fuploads%2F2021%2F08%2FBeautyScoreColorism.jpg%3Fresize%3D1200%2C600", + "publisher": "MIT Technology Review", + "timeToRead": 11 + }, + { + "category": "general", + "url": "https://getpocket.com/explore/item/how-to-get-rid-of-black-mold-naturally", + "title": "How to Get Rid of Black Mold Naturally", + "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpocket-image-cache.com%2F1200x%2Ffilters%3Aformat(jpg)%3Aextract_focal()%2Fhttps%253A%252F%252Fpocket-syndicated-images.s3.amazonaws.com%252Farticles%252F6757%252F1628024495_6109ae86db6cc.png", + "publisher": "Pocket", + "timeToRead": 4 + } + ] +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story.json b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story.json new file mode 100644 index 0000000000..a8b2d9bd70 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story.json @@ -0,0 +1,8 @@ +{ + "category": "career", + "url": "https://getpocket.com/explore/item/this-scheduling-strategy-can-save-you-hours-per-week", + "title": "This Scheduling Strategy Can Save You Hours Per Week", + "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpocket-image-cache.com%2F1200x%2Ffilters%3Aformat(jpg)%3Aextract_focal()%2Fhttps%253A%252F%252Fpocket-syndicated-images.s3.amazonaws.com%252Farticles%252F6668%252F1627343665_GettyImages-1189531274.jpg", + "publisher": "Pocket", + "timeToRead": 3 +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story_recommendation_null_imageUrl_response.json b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story_recommendation_null_imageUrl_response.json new file mode 100644 index 0000000000..3f05508681 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story_recommendation_null_imageUrl_response.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + { + "category": "science", + "url": "https://getpocket.com/explore/item/you-think-you-know-what-blue-is-but-you-have-no-idea", + "title": "You Think You Know What Blue Is, But You Have No Idea", + "imageUrl": null, + "publisher": "Pocket", + "timeToRead": 3 + } + ] +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story_recommendation_null_title_response.json b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story_recommendation_null_title_response.json new file mode 100644 index 0000000000..9ca1105afe --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story_recommendation_null_title_response.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + { + "category": "science", + "url": "https://getpocket.com/explore/item/you-think-you-know-what-blue-is-but-you-have-no-idea", + "title": null, + "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpocket-image-cache.com%2F1200x%2Ffilters%3Aformat(jpg)%3Aextract_focal()%2Fhttps%253A%252F%252Fpocket-syndicated-images.s3.amazonaws.com%252Farticles%252F3713%252F1584373694_GettyImages-83522858.jpg", + "publisher": "Pocket", + "timeToRead": 3 + } + ] +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story_recommendation_null_url_response.json b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story_recommendation_null_url_response.json new file mode 100644 index 0000000000..39483424be --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story_recommendation_null_url_response.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + { + "category": "science", + "url": null, + "title": "You Think You Know What Blue Is, But You Have No Idea", + "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpocket-image-cache.com%2F1200x%2Ffilters%3Aformat(jpg)%3Aextract_focal()%2Fhttps%253A%252F%252Fpocket-syndicated-images.s3.amazonaws.com%252Farticles%252F3713%252F1584373694_GettyImages-83522858.jpg", + "publisher": "Pocket", + "timeToRead": 3 + } + ] +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story_recommendation_response.json b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story_recommendation_response.json new file mode 100644 index 0000000000..8fa6e33ad7 --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/resources/pocket/story_recommendation_response.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + { + "category": "science", + "url": "https://getpocket.com/explore/item/you-think-you-know-what-blue-is-but-you-have-no-idea", + "title": "You Think You Know What Blue Is, But You Have No Idea", + "imageUrl": "https://img-getpocket.cdn.mozilla.net/{wh}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/https%3A%2F%2Fpocket-image-cache.com%2F1200x%2Ffilters%3Aformat(jpg)%3Aextract_focal()%2Fhttps%253A%252F%252Fpocket-syndicated-images.s3.amazonaws.com%252Farticles%252F3713%252F1584373694_GettyImages-83522858.jpg", + "publisher": "Pocket", + "timeToRead": 3 + } + ] +} diff --git a/mobile/android/android-components/components/service/pocket/src/test/resources/robolectric.properties b/mobile/android/android-components/components/service/pocket/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/service/pocket/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/mobile/android/android-components/components/service/sync-autofill/README.md b/mobile/android/android-components/components/service/sync-autofill/README.md new file mode 100644 index 0000000000..b4c1a95a85 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-autofill/README.md @@ -0,0 +1,19 @@ +# [Android Components](../../../README.md) > Service > Firefox Sync - Autofill + +A library for autofilling addresses and credit cards based on `concept-storage` backed by [application-services' Autofill lib](https://github.com/mozilla/application-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-sync-autofill:{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/sync-autofill/build.gradle b/mobile/android/android-components/components/service/sync-autofill/build.gradle new file mode 100644 index 0000000000..ecb7d7d8fb --- /dev/null +++ b/mobile/android/android-components/components/service/sync-autofill/build.gradle @@ -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/. */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + namespace 'mozilla.components.browser.storage.sync.autofill' +} + +dependencies { + api ComponentsDependencies.mozilla_appservices_autofill + + api project(':concept-storage') + api project(':concept-sync') + api project(':concept-base') + api project(':lib-dataprotect') + + implementation project(':support-utils') + implementation project(':support-ktx') + + testImplementation project(':support-test') + + testImplementation ComponentsDependencies.androidx_test_core + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.testing_robolectric + testImplementation ComponentsDependencies.testing_coroutines + testImplementation ComponentsDependencies.mozilla_appservices_full_megazord_forUnitTests +} + +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/sync-autofill/proguard-rules.pro b/mobile/android/android-components/components/service/sync-autofill/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/service/sync-autofill/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/sync-autofill/src/main/AndroidManifest.xml b/mobile/android/android-components/components/service/sync-autofill/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e16cda1d34 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-autofill/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/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/AutofillCreditCardsAddressesStorage.kt b/mobile/android/android-components/components/service/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/AutofillCreditCardsAddressesStorage.kt new file mode 100644 index 0000000000..c48559577d --- /dev/null +++ b/mobile/android/android-components/components/service/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/AutofillCreditCardsAddressesStorage.kt @@ -0,0 +1,209 @@ +/* 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.sync.autofill + +import android.content.Context +import androidx.annotation.GuardedBy +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.withContext +import mozilla.appservices.autofill.AutofillApiException.NoSuchRecord +import mozilla.components.concept.storage.Address +import mozilla.components.concept.storage.CreditCard +import mozilla.components.concept.storage.CreditCardNumber +import mozilla.components.concept.storage.CreditCardsAddressesStorage +import mozilla.components.concept.storage.NewCreditCardFields +import mozilla.components.concept.storage.UpdatableAddressFields +import mozilla.components.concept.storage.UpdatableCreditCardFields +import mozilla.components.concept.sync.SyncableStore +import mozilla.components.lib.dataprotect.SecureAbove22Preferences +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.utils.logElapsedTime +import java.io.Closeable +import mozilla.appservices.autofill.Store as RustAutofillStorage + +const val AUTOFILL_DB_NAME = "autofill.sqlite" + +/** + * An implementation of [CreditCardsAddressesStorage] backed by the application-services' `autofill` + * library. + * + * @param context A [Context] used for disk access. + * @param securePrefs A [SecureAbove22Preferences] wrapped in [Lazy] to avoid eager instantiation. + * Used for storing encryption key material. + */ +class AutofillCreditCardsAddressesStorage( + context: Context, + securePrefs: Lazy<SecureAbove22Preferences>, +) : CreditCardsAddressesStorage, SyncableStore, AutoCloseable { + private val logger = Logger("AutofillCCAddressesStorage") + + private val coroutineContext by lazy { Dispatchers.IO } + + val crypto by lazy { AutofillCrypto(context, securePrefs.value, this) } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal val conn by lazy { + AutofillStorageConnection.init(dbPath = context.getDatabasePath(AUTOFILL_DB_NAME).absolutePath) + AutofillStorageConnection + } + + /** + * "Warms up" this storage layer by establishing the database connection. + */ + suspend fun warmUp() = withContext(coroutineContext) { + logElapsedTime(logger, "Warming up storage") { conn } + Unit + } + + override suspend fun addCreditCard( + creditCardFields: NewCreditCardFields, + ): CreditCard = withContext(coroutineContext) { + val key = crypto.getOrGenerateKey() + + // Assume our key is good, and that this operation shouldn't fail. + val encryptedCardNumber = crypto.encrypt(key, creditCardFields.plaintextCardNumber)!! + val updatableCreditCardFields = UpdatableCreditCardFields( + billingName = creditCardFields.billingName, + cardNumber = encryptedCardNumber, + cardNumberLast4 = creditCardFields.cardNumberLast4, + expiryMonth = creditCardFields.expiryMonth, + expiryYear = creditCardFields.expiryYear, + cardType = creditCardFields.cardType, + ) + + conn.getStorage().addCreditCard(updatableCreditCardFields.into()).into() + } + + override suspend fun updateCreditCard( + guid: String, + creditCardFields: UpdatableCreditCardFields, + ) = withContext(coroutineContext) { + val updatableCreditCardFields = when (creditCardFields.cardNumber) { + // If credit card number changed, we need to encrypt it. + is CreditCardNumber.Plaintext -> { + val key = crypto.getOrGenerateKey() + // Assume our key is good, and that this operation shouldn't fail. + val encryptedCardNumber = crypto.encrypt( + key, + creditCardFields.cardNumber as CreditCardNumber.Plaintext, + )!! + UpdatableCreditCardFields( + billingName = creditCardFields.billingName, + cardNumber = encryptedCardNumber, + cardNumberLast4 = creditCardFields.cardNumberLast4, + expiryMonth = creditCardFields.expiryMonth, + expiryYear = creditCardFields.expiryYear, + cardType = creditCardFields.cardType, + ) + } + // If card number didn't change, we're just round-tripping an existing encrypted version. + is CreditCardNumber.Encrypted -> { + UpdatableCreditCardFields( + billingName = creditCardFields.billingName, + cardNumber = creditCardFields.cardNumber, + cardNumberLast4 = creditCardFields.cardNumberLast4, + expiryMonth = creditCardFields.expiryMonth, + expiryYear = creditCardFields.expiryYear, + cardType = creditCardFields.cardType, + ) + } + } + conn.getStorage().updateCreditCard(guid, updatableCreditCardFields.into()) + } + + override suspend fun getCreditCard(guid: String): CreditCard? = withContext(coroutineContext) { + try { + conn.getStorage().getCreditCard(guid).into() + } catch (e: NoSuchRecord) { + null + } + } + + override suspend fun getAllCreditCards(): List<CreditCard> = withContext(coroutineContext) { + conn.getStorage().getAllCreditCards().map { it.into() } + } + + override suspend fun deleteCreditCard(guid: String): Boolean = withContext(coroutineContext) { + conn.getStorage().deleteCreditCard(guid) + } + + override suspend fun touchCreditCard(guid: String) = withContext(coroutineContext) { + conn.getStorage().touchCreditCard(guid) + } + + override suspend fun addAddress(addressFields: UpdatableAddressFields): Address = + withContext(coroutineContext) { + conn.getStorage().addAddress(addressFields.into()).into() + } + + override suspend fun getAddress(guid: String): Address? = withContext(coroutineContext) { + try { + conn.getStorage().getAddress(guid).into() + } catch (e: NoSuchRecord) { + null + } + } + + override suspend fun getAllAddresses(): List<Address> = withContext(coroutineContext) { + conn.getStorage().getAllAddresses().map { it.into() } + } + + override suspend fun updateAddress(guid: String, address: UpdatableAddressFields) = + withContext(coroutineContext) { + conn.getStorage().updateAddress(guid, address.into()) + } + + override suspend fun deleteAddress(guid: String): Boolean = withContext(coroutineContext) { + conn.getStorage().deleteAddress(guid) + } + + override suspend fun touchAddress(guid: String) = withContext(coroutineContext) { + conn.getStorage().touchAddress(guid) + } + + override fun getCreditCardCrypto(): AutofillCrypto { + return crypto + } + + override suspend fun scrubEncryptedData() = withContext(coroutineContext) { + conn.getStorage().scrubEncryptedData() + } + + override fun registerWithSyncManager() { + conn.getStorage().registerWithSyncManager() + } + + override fun close() { + coroutineContext.cancel() + conn.close() + } +} + +/** + * A singleton wrapping a [RustAutofillStorage] connection. + */ +internal object AutofillStorageConnection : Closeable { + @GuardedBy("this") + private var storage: RustAutofillStorage? = null + + internal fun init(dbPath: String = AUTOFILL_DB_NAME) = synchronized(this) { + if (storage == null) { + storage = RustAutofillStorage(dbPath) + } + } + + internal fun getStorage(): RustAutofillStorage = synchronized(this) { + check(storage != null) { "must call init first" } + return storage!! + } + + override fun close() = synchronized(this) { + check(storage != null) { "must call init first" } + storage!!.destroy() + storage = null + } +} diff --git a/mobile/android/android-components/components/service/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/AutofillCrypto.kt b/mobile/android/android-components/components/service/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/AutofillCrypto.kt new file mode 100644 index 0000000000..9846280958 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/AutofillCrypto.kt @@ -0,0 +1,111 @@ +/* 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.sync.autofill + +import android.content.Context +import android.content.SharedPreferences +import mozilla.appservices.autofill.AutofillApiException +import mozilla.appservices.autofill.decryptString +import mozilla.appservices.autofill.encryptString +import mozilla.components.concept.storage.CreditCardCrypto +import mozilla.components.concept.storage.CreditCardNumber +import mozilla.components.concept.storage.KeyGenerationReason +import mozilla.components.concept.storage.KeyManager +import mozilla.components.concept.storage.ManagedKey +import mozilla.components.lib.dataprotect.SecureAbove22Preferences +import mozilla.components.support.base.log.logger.Logger + +/** + * A class that knows how to encrypt & decrypt strings, backed by application-services' autofill lib. + * Used for protecting credit card numbers at rest. + * + * This class manages creation and storage of the encryption key. + * It also keeps track of abnormal events, such as managed key going missing or getting corrupted. + * + * @param context [Context] used for obtaining [SharedPreferences] for managing internal prefs. + * @param securePrefs A [SecureAbove22Preferences] instance used for storing the managed key. + */ +class AutofillCrypto( + private val context: Context, + private val securePrefs: SecureAbove22Preferences, + private val storage: AutofillCreditCardsAddressesStorage, +) : CreditCardCrypto, KeyManager() { + private val logger = Logger("AutofillCrypto") + private val plaintextPrefs by lazy { context.getSharedPreferences(AUTOFILL_PREFS, Context.MODE_PRIVATE) } + + override fun encrypt( + key: ManagedKey, + plaintextCardNumber: CreditCardNumber.Plaintext, + ): CreditCardNumber.Encrypted? { + return try { + CreditCardNumber.Encrypted(encryptString(key.key, plaintextCardNumber.number)) + } catch (e: AutofillApiException) { + logger.warn("Failed to encrypt", e) + null + } + } + + override fun decrypt( + key: ManagedKey, + encryptedCardNumber: CreditCardNumber.Encrypted, + ): CreditCardNumber.Plaintext? { + if (encryptedCardNumber.number.isEmpty()) { + logger.info("Skipping decryption of previously scrubbed CC number") + return null + } + return try { + CreditCardNumber.Plaintext(decryptString(key.key, encryptedCardNumber.number)) + } catch (e: AutofillApiException) { + logger.warn("Failed to decrypt", e) + null + } + } + + override fun createKey() = mozilla.appservices.autofill.createKey() + + override fun isKeyRecoveryNeeded(rawKey: String, canary: String): KeyGenerationReason.RecoveryNeeded? { + return try { + if (CANARY_PHRASE_PLAINTEXT == decryptString(rawKey, canary)) { + null + } else { + KeyGenerationReason.RecoveryNeeded.Corrupt + } + } catch (e: AutofillApiException) { + KeyGenerationReason.RecoveryNeeded.Corrupt + } + } + + override fun getStoredCanary(): String? { + return plaintextPrefs.getString(CANARY_PHRASE_CIPHERTEXT_KEY, null) + } + + override fun getStoredKey(): String? { + return securePrefs.getString(AUTOFILL_KEY) + } + + override fun storeKeyAndCanary(key: String) { + // To consider: should this be a non-destructive operation, just in case? + // e.g. if we thought we lost the key, but actually did not, that would let us recover data later on. + // otherwise, if we mess up and override a perfectly good key, the data is gone for good. + securePrefs.putString(AUTOFILL_KEY, key) + // To detect key corruption or absence, use the newly generated key to encrypt a known string. + // See isKeyValid below. + plaintextPrefs + .edit() + .putString(CANARY_PHRASE_CIPHERTEXT_KEY, encryptString(key, CANARY_PHRASE_PLAINTEXT)) + .apply() + } + + override suspend fun recoverFromKeyLoss(reason: KeyGenerationReason.RecoveryNeeded) { + storage.scrubEncryptedData() + } + + companion object { + const val AUTOFILL_PREFS = "autofillCrypto" + const val AUTOFILL_KEY = "autofillKey" + const val CANARY_PHRASE_CIPHERTEXT_KEY = "canaryPhrase" + const val CANARY_PHRASE_PLAINTEXT = "a string for checking validity of the key" + } +} diff --git a/mobile/android/android-components/components/service/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/DefaultCreditCardValidationDelegate.kt b/mobile/android/android-components/components/service/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/DefaultCreditCardValidationDelegate.kt new file mode 100644 index 0000000000..d3e6122fcb --- /dev/null +++ b/mobile/android/android-components/components/service/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/DefaultCreditCardValidationDelegate.kt @@ -0,0 +1,51 @@ +/* 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.sync.autofill + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import mozilla.components.concept.storage.CreditCard +import mozilla.components.concept.storage.CreditCardEntry +import mozilla.components.concept.storage.CreditCardValidationDelegate +import mozilla.components.concept.storage.CreditCardValidationDelegate.Result +import mozilla.components.concept.storage.CreditCardsAddressesStorage + +/** + * A delegate that will check against the [CreditCardsAddressesStorage] to determine if a given + * [CreditCard] can be persisted and returns information about why it can or cannot. + * + * @param storage An instance of [CreditCardsAddressesStorage]. + */ +class DefaultCreditCardValidationDelegate( + private val storage: Lazy<CreditCardsAddressesStorage>, +) : CreditCardValidationDelegate { + + private val coroutineContext by lazy { Dispatchers.IO } + + override suspend fun shouldCreateOrUpdate(creditCard: CreditCardEntry): Result = + withContext(coroutineContext) { + val creditCards = storage.value.getAllCreditCards() + + val foundCreditCard = if (creditCards.isEmpty()) { + // No credit cards exist in the storage -> create a new credit card + null + } else { + val crypto = storage.value.getCreditCardCrypto() + val key = crypto.getOrGenerateKey() + + creditCards.find { + val cardNumber = crypto.decrypt(key, it.encryptedCardNumber)?.number + + it.guid == creditCard.guid || cardNumber == creditCard.number + } + } + + if (foundCreditCard == null) { + Result.CanBeCreated + } else { + Result.CanBeUpdated(foundCreditCard) + } + } +} diff --git a/mobile/android/android-components/components/service/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/Errors.kt b/mobile/android/android-components/components/service/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/Errors.kt new file mode 100644 index 0000000000..9e1ef01e40 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/Errors.kt @@ -0,0 +1,27 @@ +/* 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.sync.autofill + +/** + * Unrecoverable errors related to [AutofillCreditCardsAddressesStorage]. + * Do not catch these. + */ +internal sealed class AutofillStorageException(reason: Exception? = null) : RuntimeException(reason) { + /** + * Thrown if an attempt was made to persist a plaintext version of a credit card number. + */ + class TriedToPersistPlaintextCardNumber : AutofillStorageException() +} + +/** + * Unrecoverable errors related to [AutofillCrypto]. + * Do not catch these. + */ +internal sealed class AutofillCryptoException(cause: Exception? = null) : RuntimeException(cause) { + /** + * Thrown if [AutofillCrypto] encounters an unexpected, unrecoverable state. + */ + class IllegalState : AutofillCryptoException() +} diff --git a/mobile/android/android-components/components/service/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/GeckoCreditCardsAddressesStorageDelegate.kt b/mobile/android/android-components/components/service/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/GeckoCreditCardsAddressesStorageDelegate.kt new file mode 100644 index 0000000000..c040fbd68f --- /dev/null +++ b/mobile/android/android-components/components/service/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/GeckoCreditCardsAddressesStorageDelegate.kt @@ -0,0 +1,107 @@ +/* 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.sync.autofill + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mozilla.components.concept.storage.Address +import mozilla.components.concept.storage.CreditCard +import mozilla.components.concept.storage.CreditCardEntry +import mozilla.components.concept.storage.CreditCardNumber +import mozilla.components.concept.storage.CreditCardValidationDelegate +import mozilla.components.concept.storage.CreditCardsAddressesStorage +import mozilla.components.concept.storage.CreditCardsAddressesStorageDelegate +import mozilla.components.concept.storage.ManagedKey +import mozilla.components.concept.storage.NewCreditCardFields +import mozilla.components.concept.storage.UpdatableCreditCardFields +import mozilla.components.support.ktx.kotlin.last4Digits + +/** + * [CreditCardsAddressesStorageDelegate] implementation. + * + * @param storage The [CreditCardsAddressesStorage] used for looking up addresses and credit cards to autofill. + * @param scope [CoroutineScope] for long running operations. Defaults to using the [Dispatchers.IO]. + * @param isCreditCardAutofillEnabled callback allowing to limit [storage] operations if autofill is disabled. + * @param validationDelegate The [DefaultCreditCardValidationDelegate] used to check if a credit card + * can be saved in [storage] and returns information about why it can or cannot + */ +class GeckoCreditCardsAddressesStorageDelegate( + private val storage: Lazy<CreditCardsAddressesStorage>, + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), + private val validationDelegate: DefaultCreditCardValidationDelegate = DefaultCreditCardValidationDelegate(storage), + private val isCreditCardAutofillEnabled: () -> Boolean = { false }, + private val isAddressAutofillEnabled: () -> Boolean = { false }, +) : CreditCardsAddressesStorageDelegate { + + override suspend fun getOrGenerateKey(): ManagedKey { + val crypto = storage.value.getCreditCardCrypto() + return crypto.getOrGenerateKey() + } + + override suspend fun decrypt( + key: ManagedKey, + encryptedCardNumber: CreditCardNumber.Encrypted, + ): CreditCardNumber.Plaintext? { + val crypto = storage.value.getCreditCardCrypto() + return crypto.decrypt(key, encryptedCardNumber) + } + + override suspend fun onAddressesFetch(): List<Address> = withContext(scope.coroutineContext) { + if (!isAddressAutofillEnabled()) { + emptyList() + } else { + storage.value.getAllAddresses() + } + } + + override suspend fun onAddressSave(address: Address) { + TODO("Not yet implemented") + } + + override suspend fun onCreditCardsFetch(): List<CreditCard> = + withContext(scope.coroutineContext) { + if (!isCreditCardAutofillEnabled()) { + emptyList() + } else { + storage.value.getAllCreditCards() + } + } + + override suspend fun onCreditCardSave(creditCard: CreditCardEntry) { + if (!creditCard.isValid) return + + scope.launch { + when (val result = validationDelegate.shouldCreateOrUpdate(creditCard)) { + is CreditCardValidationDelegate.Result.CanBeCreated -> { + storage.value.addCreditCard( + NewCreditCardFields( + billingName = creditCard.name, + plaintextCardNumber = CreditCardNumber.Plaintext(creditCard.number), + cardNumberLast4 = creditCard.number.last4Digits(), + expiryMonth = creditCard.expiryMonth.toLong(), + expiryYear = creditCard.expiryYear.toLong(), + cardType = creditCard.cardType, + ), + ) + } + is CreditCardValidationDelegate.Result.CanBeUpdated -> { + storage.value.updateCreditCard( + guid = result.foundCreditCard.guid, + creditCardFields = UpdatableCreditCardFields( + billingName = creditCard.name, + cardNumber = CreditCardNumber.Plaintext(creditCard.number), + cardNumberLast4 = creditCard.number.last4Digits(), + expiryMonth = creditCard.expiryMonth.toLong(), + expiryYear = creditCard.expiryYear.toLong(), + cardType = creditCard.cardType, + ), + ) + } + } + } + } +} diff --git a/mobile/android/android-components/components/service/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/Types.kt b/mobile/android/android-components/components/service/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/Types.kt new file mode 100644 index 0000000000..5e222e2ea1 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-autofill/src/main/java/mozilla/components/service/sync/autofill/Types.kt @@ -0,0 +1,92 @@ +/* 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.sync.autofill + +import mozilla.components.concept.storage.Address +import mozilla.components.concept.storage.CreditCard +import mozilla.components.concept.storage.CreditCardNumber +import mozilla.components.concept.storage.UpdatableAddressFields +import mozilla.components.concept.storage.UpdatableCreditCardFields + +// We have type definitions at the concept level, and "external" types defined within Autofill. +// In practice these two types are largely the same, and this file is the conversion point. + +/** + * Conversion from a generic [UpdatableAddressFields] into its richer comrade within the 'autofill' lib. + */ +internal fun UpdatableAddressFields.into(): mozilla.appservices.autofill.UpdatableAddressFields { + return mozilla.appservices.autofill.UpdatableAddressFields( + name = this.name, + organization = this.organization, + streetAddress = this.streetAddress, + addressLevel3 = this.addressLevel3, + addressLevel2 = this.addressLevel2, + addressLevel1 = this.addressLevel1, + postalCode = this.postalCode, + country = this.country, + tel = this.tel, + email = this.email, + ) +} + +/** + * Conversion from a generic [UpdatableCreditCardFields] into its comrade within the 'autofill' lib. + */ +internal fun UpdatableCreditCardFields.into(): mozilla.appservices.autofill.UpdatableCreditCardFields { + val encryptedCardNumber = when (this.cardNumber) { + is CreditCardNumber.Encrypted -> this.cardNumber.number + is CreditCardNumber.Plaintext -> throw AutofillStorageException.TriedToPersistPlaintextCardNumber() + } + return mozilla.appservices.autofill.UpdatableCreditCardFields( + ccName = this.billingName, + ccNumberEnc = encryptedCardNumber, + ccNumberLast4 = this.cardNumberLast4, + ccExpMonth = this.expiryMonth, + ccExpYear = this.expiryYear, + ccType = this.cardType, + ) +} + +/** + * Conversion from a "native" autofill [Address] into its generic comrade. + */ +internal fun mozilla.appservices.autofill.Address.into(): Address { + return Address( + guid = this.guid, + name = this.name, + organization = this.organization, + streetAddress = this.streetAddress, + addressLevel3 = this.addressLevel3, + addressLevel2 = this.addressLevel2, + addressLevel1 = this.addressLevel1, + postalCode = this.postalCode, + country = this.country, + tel = this.tel, + email = this.email, + timeCreated = this.timeCreated, + timeLastUsed = this.timeLastUsed, + timeLastModified = this.timeLastModified, + timesUsed = this.timesUsed, + ) +} + +/** + * Conversion from a "native" autofill [CreditCard] into its generic comrade. + */ +internal fun mozilla.appservices.autofill.CreditCard.into(): CreditCard { + return CreditCard( + guid = this.guid, + billingName = this.ccName, + encryptedCardNumber = CreditCardNumber.Encrypted(this.ccNumberEnc), + cardNumberLast4 = this.ccNumberLast4, + expiryMonth = this.ccExpMonth, + expiryYear = this.ccExpYear, + cardType = this.ccType, + timeCreated = this.timeCreated, + timeLastUsed = this.timeLastUsed, + timeLastModified = this.timeLastModified, + timesUsed = this.timesUsed, + ) +} diff --git a/mobile/android/android-components/components/service/sync-autofill/src/test/java/mozilla/components/service/sync/autofill/AutofillCreditCardsAddressesStorageTest.kt b/mobile/android/android-components/components/service/sync-autofill/src/test/java/mozilla/components/service/sync/autofill/AutofillCreditCardsAddressesStorageTest.kt new file mode 100644 index 0000000000..3fa18c40ca --- /dev/null +++ b/mobile/android/android-components/components/service/sync-autofill/src/test/java/mozilla/components/service/sync/autofill/AutofillCreditCardsAddressesStorageTest.kt @@ -0,0 +1,407 @@ +/* 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.sync.autofill + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.concept.storage.CreditCard +import mozilla.components.concept.storage.CreditCardNumber +import mozilla.components.concept.storage.NewCreditCardFields +import mozilla.components.concept.storage.UpdatableAddressFields +import mozilla.components.concept.storage.UpdatableCreditCardFields +import mozilla.components.lib.dataprotect.SecureAbove22Preferences +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 +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify + +@ExperimentalCoroutinesApi // for runTest +@RunWith(AndroidJUnit4::class) +class AutofillCreditCardsAddressesStorageTest { + + private lateinit var storage: AutofillCreditCardsAddressesStorage + private lateinit var securePrefs: SecureAbove22Preferences + + @Before + fun setup() { + // forceInsecure is set in the tests because a keystore wouldn't be configured in the test environment. + securePrefs = SecureAbove22Preferences(testContext, "autofill", forceInsecure = true) + storage = AutofillCreditCardsAddressesStorage(testContext, lazy { securePrefs }) + } + + @After + fun cleanup() { + storage.close() + } + + @Test + fun `add credit card`() = runTest { + val plaintextNumber = CreditCardNumber.Plaintext("4111111111111111") + val creditCardFields = NewCreditCardFields( + billingName = "Jon Doe", + plaintextCardNumber = plaintextNumber, + cardNumberLast4 = "1111", + expiryMonth = 12, + expiryYear = 2028, + cardType = "amex", + ) + val creditCard = storage.addCreditCard(creditCardFields) + + assertNotNull(creditCard) + + assertEquals(creditCardFields.billingName, creditCard.billingName) + assertEquals(plaintextNumber, storage.crypto.decrypt(storage.crypto.getOrGenerateKey(), creditCard.encryptedCardNumber)) + assertEquals(creditCardFields.cardNumberLast4, creditCard.cardNumberLast4) + assertEquals(creditCardFields.expiryMonth, creditCard.expiryMonth) + assertEquals(creditCardFields.expiryYear, creditCard.expiryYear) + assertEquals(creditCardFields.cardType, creditCard.cardType) + assertEquals( + CreditCard.ellipsesStart + + CreditCard.ellipsis + CreditCard.ellipsis + CreditCard.ellipsis + CreditCard.ellipsis + + creditCardFields.cardNumberLast4 + + CreditCard.ellipsesEnd, + creditCard.obfuscatedCardNumber, + ) + } + + @Test + fun `get credit card`() = runTest { + val plaintextNumber = CreditCardNumber.Plaintext("5500000000000004") + val creditCardFields = NewCreditCardFields( + billingName = "Jon Doe", + plaintextCardNumber = plaintextNumber, + cardNumberLast4 = "0004", + expiryMonth = 12, + expiryYear = 2028, + cardType = "amex", + ) + val creditCard = storage.addCreditCard(creditCardFields) + + assertEquals(creditCard, storage.getCreditCard(creditCard.guid)) + } + + @Test + fun `GIVEN a non-existent credit card guid WHEN getCreditCard is called THEN null is returned`() = runTest { + assertNull(storage.getCreditCard("guid")) + } + + @Test + fun `get all credit cards`() = runTest { + val plaintextNumber1 = CreditCardNumber.Plaintext("5500000000000004") + val creditCardFields1 = NewCreditCardFields( + billingName = "Jane Fields", + plaintextCardNumber = plaintextNumber1, + cardNumberLast4 = "0004", + expiryMonth = 12, + expiryYear = 2028, + cardType = "mastercard", + ) + val plaintextNumber2 = CreditCardNumber.Plaintext("4111111111111111") + val creditCardFields2 = NewCreditCardFields( + billingName = "Banana Apple", + plaintextCardNumber = plaintextNumber2, + cardNumberLast4 = "1111", + expiryMonth = 1, + expiryYear = 2030, + cardType = "visa", + ) + val plaintextNumber3 = CreditCardNumber.Plaintext("340000000000009") + val creditCardFields3 = NewCreditCardFields( + billingName = "Pineapple Orange", + plaintextCardNumber = plaintextNumber3, + cardNumberLast4 = "0009", + expiryMonth = 2, + expiryYear = 2028, + cardType = "amex", + ) + val creditCard1 = storage.addCreditCard(creditCardFields1) + val creditCard2 = storage.addCreditCard(creditCardFields2) + val creditCard3 = storage.addCreditCard(creditCardFields3) + + val creditCards = storage.getAllCreditCards() + val key = storage.crypto.getOrGenerateKey() + + val savedCreditCard1 = creditCards.find { it == creditCard1 } + assertNotNull(savedCreditCard1) + val savedCreditCard2 = creditCards.find { it == creditCard2 } + assertNotNull(savedCreditCard2) + val savedCreditCard3 = creditCards.find { it == creditCard3 } + assertNotNull(savedCreditCard3) + + assertEquals(plaintextNumber1, storage.crypto.decrypt(key, savedCreditCard1!!.encryptedCardNumber)) + assertEquals(plaintextNumber2, storage.crypto.decrypt(key, savedCreditCard2!!.encryptedCardNumber)) + assertEquals(plaintextNumber3, storage.crypto.decrypt(key, savedCreditCard3!!.encryptedCardNumber)) + } + + @Test + fun `update credit card`() = runTest { + val creditCardFields = NewCreditCardFields( + billingName = "Jon Doe", + plaintextCardNumber = CreditCardNumber.Plaintext("4111111111111111"), + cardNumberLast4 = "1111", + expiryMonth = 12, + expiryYear = 2028, + cardType = "visa", + ) + + var creditCard = storage.addCreditCard(creditCardFields) + + // Change every field + var newCreditCardFields = UpdatableCreditCardFields( + billingName = "Jane Fields", + cardNumber = CreditCardNumber.Plaintext("30000000000004"), + cardNumberLast4 = "0004", + expiryMonth = 12, + expiryYear = 2038, + cardType = "diners", + ) + + storage.updateCreditCard(creditCard.guid, newCreditCardFields) + + creditCard = storage.getCreditCard(creditCard.guid)!! + + val key = storage.crypto.getOrGenerateKey() + + assertEquals(newCreditCardFields.billingName, creditCard.billingName) + assertEquals(newCreditCardFields.cardNumber, storage.crypto.decrypt(key, creditCard.encryptedCardNumber)) + assertEquals(newCreditCardFields.cardNumberLast4, creditCard.cardNumberLast4) + assertEquals(newCreditCardFields.expiryMonth, creditCard.expiryMonth) + assertEquals(newCreditCardFields.expiryYear, creditCard.expiryYear) + assertEquals(newCreditCardFields.cardType, creditCard.cardType) + + // Change the name only. + newCreditCardFields = UpdatableCreditCardFields( + billingName = "Bob Jones", + cardNumber = creditCard.encryptedCardNumber, + cardNumberLast4 = "0004", + expiryMonth = 12, + expiryYear = 2038, + cardType = "diners", + ) + + storage.updateCreditCard(creditCard.guid, newCreditCardFields) + + creditCard = storage.getCreditCard(creditCard.guid)!! + + assertEquals(newCreditCardFields.billingName, creditCard.billingName) + assertEquals(newCreditCardFields.cardNumber, creditCard.encryptedCardNumber) + assertEquals(newCreditCardFields.cardNumberLast4, creditCard.cardNumberLast4) + assertEquals(newCreditCardFields.expiryMonth, creditCard.expiryMonth) + assertEquals(newCreditCardFields.expiryYear, creditCard.expiryYear) + assertEquals(newCreditCardFields.cardType, creditCard.cardType) + } + + @Test + fun `delete credit card`() = runTest { + val creditCardFields = NewCreditCardFields( + billingName = "Jon Doe", + plaintextCardNumber = CreditCardNumber.Plaintext("30000000000004"), + cardNumberLast4 = "0004", + expiryMonth = 12, + expiryYear = 2028, + cardType = "diners", + ) + + val creditCard = storage.addCreditCard(creditCardFields) + assertNotNull(storage.getCreditCard(creditCard.guid)) + + val isDeleteSuccessful = storage.deleteCreditCard(creditCard.guid) + + assertTrue(isDeleteSuccessful) + assertNull(storage.getCreditCard(creditCard.guid)) + } + + @Test + fun `add address`() = runTest { + val addressFields = UpdatableAddressFields( + name = "John Smith", + organization = "Mozilla", + streetAddress = "123 Sesame Street", + addressLevel3 = "", + addressLevel2 = "", + addressLevel1 = "", + postalCode = "90210", + country = "US", + tel = "+1 519 555-5555", + email = "foo@bar.com", + ) + val address = storage.addAddress(addressFields) + + assertNotNull(address) + + assertEquals(addressFields.name, address.name) + assertEquals(addressFields.organization, address.organization) + assertEquals(addressFields.streetAddress, address.streetAddress) + assertEquals(addressFields.addressLevel3, address.addressLevel3) + assertEquals(addressFields.addressLevel2, address.addressLevel2) + assertEquals(addressFields.addressLevel1, address.addressLevel1) + assertEquals(addressFields.postalCode, address.postalCode) + assertEquals(addressFields.country, address.country) + assertEquals(addressFields.tel, address.tel) + assertEquals(addressFields.email, address.email) + } + + @Test + fun `get address`() = runTest { + val addressFields = UpdatableAddressFields( + name = "John Smith", + organization = "Mozilla", + streetAddress = "123 Sesame Street", + addressLevel3 = "", + addressLevel2 = "", + addressLevel1 = "", + postalCode = "90210", + country = "US", + tel = "+1 519 555-5555", + email = "foo@bar.com", + ) + val address = storage.addAddress(addressFields) + + assertEquals(address, storage.getAddress(address.guid)) + } + + @Test + fun `GIVEN a non-existent address guid WHEN getAddress is called THEN null is returned`() = runTest { + assertNull(storage.getAddress("guid")) + } + + @Test + fun `get all addresses`() = runTest { + val addressFields1 = UpdatableAddressFields( + name = "John Smith", + organization = "Mozilla", + streetAddress = "123 Sesame Street", + addressLevel3 = "", + addressLevel2 = "", + addressLevel1 = "", + postalCode = "90210", + country = "US", + tel = "+1 519 555-5555", + email = "foo@bar.com", + ) + val addressFields2 = UpdatableAddressFields( + name = "Mary Sue", + organization = "", + streetAddress = "1 New St", + addressLevel3 = "", + addressLevel2 = "York", + addressLevel1 = "SC", + postalCode = "29745", + country = "US", + tel = "+19871234567", + email = "mary@example.com", + ) + val addressFields3 = UpdatableAddressFields( + name = "Timothy João Berners-Lee", + organization = "World Wide Web Consortium", + streetAddress = "Rua Adalberto Pajuaba, 404", + addressLevel3 = "Campos Elísios", + addressLevel2 = "Ribeirão Preto", + addressLevel1 = "SP", + postalCode = "14055-220", + country = "BR", + tel = "+0318522222222", + email = "timbr@example.org", + ) + val address1 = storage.addAddress(addressFields1) + val address2 = storage.addAddress(addressFields2) + val address3 = storage.addAddress(addressFields3) + + val addresses = storage.getAllAddresses() + + val savedAddress1 = addresses.find { it == address1 } + assertNotNull(savedAddress1) + val savedAddress2 = addresses.find { it == address2 } + assertNotNull(savedAddress2) + val savedAddress3 = addresses.find { it == address3 } + assertNotNull(savedAddress3) + } + + @Test + fun `update address`() = runTest { + val addressFields = UpdatableAddressFields( + name = "John Smith", + organization = "Mozilla", + streetAddress = "123 Sesame Street", + addressLevel3 = "", + addressLevel2 = "", + addressLevel1 = "", + postalCode = "90210", + country = "US", + tel = "+1 519 555-5555", + email = "foo@bar.com", + ) + + var address = storage.addAddress(addressFields) + + val newAddressFields = UpdatableAddressFields( + name = "Mary Sue", + organization = "", + streetAddress = "1 New St", + addressLevel3 = "", + addressLevel2 = "York", + addressLevel1 = "SC", + postalCode = "29745", + country = "US", + tel = "+19871234567", + email = "mary@example.com", + ) + + storage.updateAddress(address.guid, newAddressFields) + + address = storage.getAddress(address.guid)!! + + assertEquals(newAddressFields.name, address.name) + assertEquals(newAddressFields.organization, address.organization) + assertEquals(newAddressFields.streetAddress, address.streetAddress) + assertEquals(newAddressFields.addressLevel3, address.addressLevel3) + assertEquals(newAddressFields.addressLevel2, address.addressLevel2) + assertEquals(newAddressFields.addressLevel1, address.addressLevel1) + assertEquals(newAddressFields.postalCode, address.postalCode) + assertEquals(newAddressFields.country, address.country) + assertEquals(newAddressFields.tel, address.tel) + assertEquals(newAddressFields.email, address.email) + } + + @Test + fun `delete address`() = runTest { + val addressFields = UpdatableAddressFields( + name = "John Smith", + organization = "Mozilla", + streetAddress = "123 Sesame Street", + addressLevel3 = "", + addressLevel2 = "", + addressLevel1 = "", + postalCode = "90210", + country = "US", + tel = "+1 519 555-5555", + email = "foo@bar.com", + ) + + val address = storage.addAddress(addressFields) + val savedAddress = storage.getAddress(address.guid) + assertEquals(address, savedAddress) + + val isDeleteSuccessful = storage.deleteAddress(address.guid) + assertTrue(isDeleteSuccessful) + assertNull(storage.getAddress(address.guid)) + } + + @Test + fun `WHEN warmUp method is called THEN the database connection is established`(): Unit = runTest { + val storageSpy = spy(storage) + storageSpy.warmUp() + + verify(storageSpy).conn + } +} diff --git a/mobile/android/android-components/components/service/sync-autofill/src/test/java/mozilla/components/service/sync/autofill/AutofillCryptoTest.kt b/mobile/android/android-components/components/service/sync-autofill/src/test/java/mozilla/components/service/sync/autofill/AutofillCryptoTest.kt new file mode 100644 index 0000000000..f0223a5f25 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-autofill/src/test/java/mozilla/components/service/sync/autofill/AutofillCryptoTest.kt @@ -0,0 +1,147 @@ +/* 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.sync.autofill + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.concept.storage.CreditCardNumber +import mozilla.components.concept.storage.KeyGenerationReason +import mozilla.components.concept.storage.ManagedKey +import mozilla.components.lib.dataprotect.SecureAbove22Preferences +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions + +@ExperimentalCoroutinesApi // for runTest +@RunWith(AndroidJUnit4::class) +class AutofillCryptoTest { + + private lateinit var securePrefs: SecureAbove22Preferences + + @Before + fun setup() { + // forceInsecure is set in the tests because a keystore wouldn't be configured in the test environment. + securePrefs = SecureAbove22Preferences(testContext, "autofill", forceInsecure = true) + } + + @Test + fun `get key - new`() = runTest { + val storage = mock<AutofillCreditCardsAddressesStorage>() + val crypto = AutofillCrypto(testContext, securePrefs, storage) + val key = crypto.getOrGenerateKey() + assertEquals(KeyGenerationReason.New, key.wasGenerated) + + // key was persisted, subsequent fetches return it. + val key2 = crypto.getOrGenerateKey() + assertNull(key2.wasGenerated) + + assertEquals(key.key, key2.key) + verifyNoInteractions(storage) + } + + @Test + fun `get key - lost`() = runTest { + val storage = mock<AutofillCreditCardsAddressesStorage>() + val crypto = AutofillCrypto(testContext, securePrefs, storage) + val key = crypto.getOrGenerateKey() + assertEquals(KeyGenerationReason.New, key.wasGenerated) + + // now, let's loose the key. It'll be regenerated + securePrefs.clear() + val key2 = crypto.getOrGenerateKey() + assertEquals(KeyGenerationReason.RecoveryNeeded.Lost, key2.wasGenerated) + + assertNotEquals(key.key, key2.key) + verify(storage).scrubEncryptedData() + } + + @Test + fun `get key - corrupted`() = runTest { + val storage = mock<AutofillCreditCardsAddressesStorage>() + val crypto = AutofillCrypto(testContext, securePrefs, storage) + val key = crypto.getOrGenerateKey() + assertEquals(KeyGenerationReason.New, key.wasGenerated) + + // now, let's corrupt the key. It'll be regenerated + securePrefs.putString(AutofillCrypto.AUTOFILL_KEY, "garbage") + + val key2 = crypto.getOrGenerateKey() + assertEquals(KeyGenerationReason.RecoveryNeeded.Corrupt, key2.wasGenerated) + + assertNotEquals(key.key, key2.key) + verify(storage).scrubEncryptedData() + } + + @Test + fun `get key - corrupted subtly`() = runTest { + val storage = mock<AutofillCreditCardsAddressesStorage>() + val crypto = AutofillCrypto(testContext, securePrefs, storage) + val key = crypto.getOrGenerateKey() + assertEquals(KeyGenerationReason.New, key.wasGenerated) + + // now, let's corrupt the key. It'll be regenerated + // this key is shaped correctly, but of course it won't be the same as what we got back in the first call to key() + securePrefs.putString(AutofillCrypto.AUTOFILL_KEY, "{\"kty\":\"oct\",\"k\":\"GhsmEtujZN_qMEgw1ZHhcJhdAFR9EkUgb94qANel-P4\"}") + + val key2 = crypto.getOrGenerateKey() + assertEquals(KeyGenerationReason.RecoveryNeeded.Corrupt, key2.wasGenerated) + + assertNotEquals(key.key, key2.key) + verify(storage).scrubEncryptedData() + } + + @Test + fun `encrypt and decrypt card - normal`() = runTest { + val crypto = AutofillCrypto(testContext, securePrefs, mock()) + val key = crypto.getOrGenerateKey() + val plaintext1 = CreditCardNumber.Plaintext("4111111111111111") + val plaintext2 = CreditCardNumber.Plaintext("4111111111111111") + + val encrypted1 = crypto.encrypt(key, plaintext1)!! + val encrypted2 = crypto.encrypt(key, plaintext2)!! + + // We use a non-deterministic encryption scheme. + assertNotEquals(encrypted1, encrypted2) + + assertEquals("4111111111111111", crypto.decrypt(key, encrypted1)!!.number) + assertEquals("4111111111111111", crypto.decrypt(key, encrypted2)!!.number) + } + + @Test + fun `encrypt and decrypt card - bad keys`() = runTest { + val crypto = AutofillCrypto(testContext, securePrefs, mock()) + val plaintext = CreditCardNumber.Plaintext("4111111111111111") + + val badKey = ManagedKey(key = "garbage", wasGenerated = null) + assertNull(crypto.encrypt(badKey, plaintext)) + + // This isn't a valid key. + val corruptKey = ManagedKey(key = "{\"kty\":\"oct\",\"k\":\"GhsmEtujZN_qMEgw1ZHhcJhdAFR9EkU\"}", wasGenerated = null) + assertNull(crypto.encrypt(corruptKey, plaintext)) + + val goodKey = crypto.getOrGenerateKey() + val encrypted = crypto.encrypt(goodKey, plaintext)!! + + assertNull(crypto.decrypt(badKey, encrypted)) + assertNull(crypto.decrypt(corruptKey, encrypted)) + } + + @Test + fun `decrypt scrubbed card`() = runTest { + val crypto = AutofillCrypto(testContext, securePrefs, mock()) + val key = crypto.getOrGenerateKey() + // if a key was previously lost we will wipe the card numbers. + val encrypted = CreditCardNumber.Encrypted("") + assertNull(crypto.decrypt(key, encrypted)) + } +} diff --git a/mobile/android/android-components/components/service/sync-autofill/src/test/java/mozilla/components/service/sync/autofill/DefaultCreditCardValidationDelegateTest.kt b/mobile/android/android-components/components/service/sync-autofill/src/test/java/mozilla/components/service/sync/autofill/DefaultCreditCardValidationDelegateTest.kt new file mode 100644 index 0000000000..1bbb8b1630 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-autofill/src/test/java/mozilla/components/service/sync/autofill/DefaultCreditCardValidationDelegateTest.kt @@ -0,0 +1,134 @@ +/* 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.sync.autofill + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import mozilla.components.concept.storage.CreditCardEntry +import mozilla.components.concept.storage.CreditCardNumber +import mozilla.components.concept.storage.CreditCardValidationDelegate.Result +import mozilla.components.concept.storage.NewCreditCardFields +import mozilla.components.lib.dataprotect.SecureAbove22Preferences +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class DefaultCreditCardValidationDelegateTest { + + private lateinit var storage: AutofillCreditCardsAddressesStorage + private lateinit var securePrefs: SecureAbove22Preferences + private lateinit var validationDelegate: DefaultCreditCardValidationDelegate + + @Before + fun before() = runBlocking { + // forceInsecure is set in the tests because a keystore wouldn't be configured in the test environment. + securePrefs = SecureAbove22Preferences(testContext, "autofill", forceInsecure = true) + storage = AutofillCreditCardsAddressesStorage(testContext, lazy { securePrefs }) + validationDelegate = DefaultCreditCardValidationDelegate(storage = lazy { storage }) + } + + @Test + fun `WHEN no credit cards exist in the storage, THEN add the new credit card to storage`() = + runBlocking { + val newCreditCard = createCreditCardEntry(guid = "1") + val result = validationDelegate.shouldCreateOrUpdate(newCreditCard) + + assertEquals(Result.CanBeCreated, result) + } + + @Test + fun `WHEN existing credit card matches by guid and card number, THEN update the credit card in storage`() = + runBlocking { + val creditCardFields = NewCreditCardFields( + billingName = "Pineapple Orange", + plaintextCardNumber = CreditCardNumber.Plaintext("4111111111111111"), + cardNumberLast4 = "1111", + expiryMonth = 12, + expiryYear = 2028, + cardType = "visa", + ) + val creditCard = storage.addCreditCard(creditCardFields) + val newCreditCard = createCreditCardEntry(guid = creditCard.guid) + val result = validationDelegate.shouldCreateOrUpdate(newCreditCard) + + assertEquals(Result.CanBeUpdated(creditCard), result) + } + + @Test + fun `WHEN existing credit card matches by guid only, THEN update the credit card in storage`() = + runBlocking { + val creditCardFields = NewCreditCardFields( + billingName = "Pineapple Orange", + plaintextCardNumber = CreditCardNumber.Plaintext("4111111111111111"), + cardNumberLast4 = "1111", + expiryMonth = 12, + expiryYear = 2028, + cardType = "visa", + ) + val creditCard = storage.addCreditCard(creditCardFields) + val newCreditCard = createCreditCardEntry(guid = creditCard.guid) + val result = validationDelegate.shouldCreateOrUpdate(newCreditCard) + + assertEquals(Result.CanBeUpdated(creditCard), result) + } + + @Test + fun `WHEN existing credit card matches by card number only, THEN update the credit card in storage`() = + runBlocking { + val creditCardFields = NewCreditCardFields( + billingName = "Pineapple Orange", + plaintextCardNumber = CreditCardNumber.Plaintext("4111111111111111"), + cardNumberLast4 = "1111", + expiryMonth = 12, + expiryYear = 2028, + cardType = "visa", + ) + val creditCard = storage.addCreditCard(creditCardFields) + val newCreditCard = createCreditCardEntry(cardNumber = "4111111111111111") + val result = validationDelegate.shouldCreateOrUpdate(newCreditCard) + + assertEquals(Result.CanBeUpdated(creditCard), result) + } + + @Test + fun `WHEN existing credit card does not match by guid and card number, THEN add the new credit card to storage`() = + runBlocking { + val newCreditCard = createCreditCardEntry(guid = "2") + val creditCardFields = NewCreditCardFields( + billingName = "Pineapple Orange", + plaintextCardNumber = CreditCardNumber.Plaintext("4111111111111111"), + cardNumberLast4 = "1111", + expiryMonth = 12, + expiryYear = 2028, + cardType = "visa", + ) + storage.addCreditCard(creditCardFields) + + val result = validationDelegate.shouldCreateOrUpdate(newCreditCard) + + assertEquals(Result.CanBeCreated, result) + } +} + +fun createCreditCardEntry( + guid: String = "id", + billingName: String = "Banana Apple", + cardNumber: String = "4111111111111110", + expiryMonth: String = "1", + expiryYear: String = "2030", + cardType: String = "amex", +) = CreditCardEntry( + guid = guid, + name = billingName, + number = cardNumber, + expiryMonth = expiryMonth, + expiryYear = expiryYear, + cardType = cardType, +) diff --git a/mobile/android/android-components/components/service/sync-autofill/src/test/java/mozilla/components/service/sync/autofill/GeckoCreditCardsAddressesStorageDelegateTest.kt b/mobile/android/android-components/components/service/sync-autofill/src/test/java/mozilla/components/service/sync/autofill/GeckoCreditCardsAddressesStorageDelegateTest.kt new file mode 100644 index 0000000000..9974f055a1 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-autofill/src/test/java/mozilla/components/service/sync/autofill/GeckoCreditCardsAddressesStorageDelegateTest.kt @@ -0,0 +1,243 @@ +/* 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.sync.autofill + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import mozilla.components.concept.storage.Address +import mozilla.components.concept.storage.CreditCard +import mozilla.components.concept.storage.CreditCardEntry +import mozilla.components.concept.storage.CreditCardNumber +import mozilla.components.concept.storage.CreditCardValidationDelegate +import mozilla.components.concept.storage.NewCreditCardFields +import mozilla.components.concept.storage.UpdatableCreditCardFields +import mozilla.components.lib.dataprotect.SecureAbove22Preferences +import mozilla.components.support.ktx.kotlin.last4Digits +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.times +import org.mockito.Mockito.verify + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class GeckoCreditCardsAddressesStorageDelegateTest { + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + private val scope = coroutinesTestRule.scope + + private lateinit var storage: AutofillCreditCardsAddressesStorage + private lateinit var securePrefs: SecureAbove22Preferences + private lateinit var delegate: GeckoCreditCardsAddressesStorageDelegate + private val validationDelegate: DefaultCreditCardValidationDelegate = mock() + + init { + testContext.getDatabasePath(AUTOFILL_DB_NAME)!!.parentFile!!.mkdirs() + } + + @Before + fun before() { + // forceInsecure is set in the tests because a keystore wouldn't be configured in the test environment. + securePrefs = SecureAbove22Preferences(testContext, "autofill", forceInsecure = true) + storage = spy(AutofillCreditCardsAddressesStorage(testContext, lazy { securePrefs })) + delegate = GeckoCreditCardsAddressesStorageDelegate(lazy { storage }, scope, validationDelegate) + } + + @Test + fun `GIVEN a newly added credit card WHEN decrypt is called THEN it returns the plain credit card number`() = + runTestOnMain { + val plaintextNumber = CreditCardNumber.Plaintext("4111111111111111") + val creditCardFields = NewCreditCardFields( + billingName = "Jon Doe", + plaintextCardNumber = plaintextNumber, + cardNumberLast4 = "1111", + expiryMonth = 12, + expiryYear = 2028, + cardType = "amex", + ) + val creditCard = storage.addCreditCard(creditCardFields) + val key = delegate.getOrGenerateKey() + + assertEquals( + plaintextNumber, + delegate.decrypt(key, creditCard.encryptedCardNumber), + ) + } + + @Test + fun `GIVEN autofill enabled WHEN onCreditCardsFetch is called THEN it returns all stored cards`() = + runTest { + val storage: AutofillCreditCardsAddressesStorage = mock() + val storedCards = listOf<CreditCard>(mock()) + doReturn(storedCards).`when`(storage).getAllCreditCards() + delegate = GeckoCreditCardsAddressesStorageDelegate(lazy { storage }, scope, isCreditCardAutofillEnabled = { true }) + + val result = delegate.onCreditCardsFetch() + + verify(storage, times(1)).getAllCreditCards() + assertEquals(storedCards, result) + } + + @Test + fun `GIVEN autofill disabled WHEN onCreditCardsFetch is called THEN it returns an empty list of cards`() = + runTest { + val storage: AutofillCreditCardsAddressesStorage = mock() + val storedCards = listOf<CreditCard>(mock()) + doReturn(storedCards).`when`(storage).getAllCreditCards() + delegate = GeckoCreditCardsAddressesStorageDelegate(lazy { storage }, scope, isCreditCardAutofillEnabled = { false }) + + val result = delegate.onCreditCardsFetch() + + verify(storage, never()).getAllCreditCards() + assertEquals(emptyList<CreditCard>(), result) + } + + @Test + fun `GIVEN a new credit card WHEN onCreditCardSave is called THEN it adds a new credit card in storage`() { + runTest { + val billingName = "Jon Doe" + val cardNumber = "4111111111111111" + val expiryMonth = 12L + val expiryYear = 2028L + val cardType = "amex" + + val creditCardEntry = CreditCardEntry( + name = billingName, + number = cardNumber, + expiryMonth = expiryMonth.toString(), + expiryYear = expiryYear.toString(), + cardType = cardType, + ) + + doReturn(CreditCardValidationDelegate.Result.CanBeCreated).`when`(validationDelegate).shouldCreateOrUpdate(creditCardEntry) + + delegate.onCreditCardSave(creditCardEntry) + + verify(storage, times(1)).addCreditCard( + creditCardFields = NewCreditCardFields( + billingName = billingName, + plaintextCardNumber = CreditCardNumber.Plaintext(cardNumber), + cardNumberLast4 = cardNumber.last4Digits(), + expiryMonth = expiryMonth, + expiryYear = expiryYear, + cardType = cardType, + ), + ) + } + } + + @Test + fun `GIVEN an existing credit card WHEN onCreditCardSave is called THEN it updates the existing credit card in storage`() { + runTest { + val billingName = "Jon Doe" + val cardNumber = "4111111111111111" + val expiryMonth = 12L + val expiryYear = 2028L + val cardType = "amex" + + val creditCardEntry = CreditCardEntry( + name = billingName, + number = cardNumber, + expiryMonth = expiryMonth.toString(), + expiryYear = expiryYear.toString(), + cardType = cardType, + ) + + val creditCard = storage.addCreditCard( + NewCreditCardFields( + billingName = billingName, + plaintextCardNumber = CreditCardNumber.Plaintext(cardNumber), + cardNumberLast4 = "1111", + expiryMonth = expiryMonth, + expiryYear = expiryYear, + cardType = cardType, + ), + ) + doReturn(CreditCardValidationDelegate.Result.CanBeUpdated(creditCard)).`when`(validationDelegate).shouldCreateOrUpdate(creditCardEntry) + + delegate.onCreditCardSave( + creditCardEntry, + ) + + verify(storage, times(1)).updateCreditCard( + guid = creditCard.guid, + creditCardFields = UpdatableCreditCardFields( + billingName = billingName, + cardNumber = CreditCardNumber.Plaintext("4111111111111111"), + cardNumberLast4 = "4111111111111111".last4Digits(), + expiryMonth = expiryMonth, + expiryYear = expiryYear, + cardType = cardType, + ), + ) + } + } + + @Test + fun `GIVEN an invalid credit card entry WHEN onCreditCardSave is called THEN the request is ignored`() { + runTest { + val billingName = "Jon Doe" + val cardNumber = "" + val expiryMonth = "" + val expiryYear = "" + val cardType = "amex" + + val creditCardEntry = CreditCardEntry( + name = billingName, + number = cardNumber, + expiryMonth = expiryMonth, + expiryYear = expiryYear, + cardType = cardType, + ) + + delegate.onCreditCardSave( + creditCardEntry, + ) + + verify(storage, times(0)).addCreditCard(any()) + verify(storage, times(0)).updateCreditCard(any(), any()) + } + } + + @Test + fun `GIVEN address autofill is enabled WHEN onAddressesFetch is called THEN it returns all stored addresses`() = + runTest { + val storage: AutofillCreditCardsAddressesStorage = mock() + val storedAddresses = listOf<Address>(mock(), mock()) + doReturn(storedAddresses).`when`(storage).getAllAddresses() + delegate = GeckoCreditCardsAddressesStorageDelegate(lazy { storage }, scope, isAddressAutofillEnabled = { true }) + + val result = delegate.onAddressesFetch() + + verify(storage, times(1)).getAllAddresses() + assertEquals(storedAddresses, result) + } + + @Test + fun `GIVEN address autofill is disabled WHEN onAddressesFetch is called THEN it returns an empty list of addresses`() = + runTest { + val storage: AutofillCreditCardsAddressesStorage = mock() + val storedCards = listOf<CreditCard>(mock()) + doReturn(storedCards).`when`(storage).getAllCreditCards() + delegate = GeckoCreditCardsAddressesStorageDelegate(lazy { storage }, scope, isAddressAutofillEnabled = { false }) + + val result = delegate.onAddressesFetch() + + verify(storage, never()).getAllAddresses() + assertEquals(emptyList<CreditCard>(), result) + } +} diff --git a/mobile/android/android-components/components/service/sync-autofill/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/service/sync-autofill/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..ca6ee9cea8 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-autofill/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline
\ No newline at end of file diff --git a/mobile/android/android-components/components/service/sync-autofill/src/test/resources/robolectric.properties b/mobile/android/android-components/components/service/sync-autofill/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/service/sync-autofill/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/mobile/android/android-components/components/service/sync-logins/README.md b/mobile/android/android-components/components/service/sync-logins/README.md new file mode 100644 index 0000000000..8cf3711bb4 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/README.md @@ -0,0 +1,76 @@ +# [Android Components](../../../README.md) > Service > Firefox Sync - Logins + +A library for integrating with Firefox Sync - Logins. + +## Before using this component +Products sending telemetry and using this component *must request* a data-review following [this process](https://wiki.mozilla.org/Firefox/Data_Collection). +This component provides data collection using the [Glean SDK](https://mozilla.github.io/glean/book/index.html). +The list of metrics being collected is available in the [metrics documentation](../../support/sync-telemetry/docs/metrics.md). + +## Motivation + +The **Firefox Sync - Logins Component** provides a way for Android applications to do the following: + +* Retrieve the Logins (url / password) data type from [Firefox Sync](https://www.mozilla.org/en-US/firefox/features/sync/) + +## Usage + +### Before using this component + +The `mozilla.appservices.logins` component collects telemetry using the [Glean SDK](https://mozilla.github.io/glean/). +Applications that send telemetry via Glean *must ensure* they have received appropriate data-review following +[the Firefox Data Collection process](https://wiki.mozilla.org/Firefox/Data_Collection) before integrating this component. + +Details on the metrics collected by the `mozilla.appservices.logins` component are available +[here](https://github.com/mozilla/application-services/tree/main/docs/metrics/logins/metrics.md) + + +### Setting up the dependency + +Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)): + +``` +implementation "org.mozilla.components:service-sync-logins:{latest-version}" +``` + +You will also need the Firefox Accounts component to be able to obtain the keys to decrypt the Logins data: + +``` +implementation "org.mozilla.components:fxa:{latest-version} +``` + +### Known Issues + +* Android 6.0 is temporary not supported and will probably crash the application. + +## API + +This implements the login-related interfaces from `mozilla.components.concept.storage`. + +## FAQ + +### Which exceptions do I need to handle? + +It depends, but probably only `SyncAuthInvalid`, but potentially `IncorrectKey`. + +- You need to handle `SyncAuthInvalid`. You can do this by refreshing the FxA authentication (you should only do this once, and not in e.g. a loop). Most/All consumers will need to do this. + +- `IncorrectKey`: If you're sure the key you have used is valid, the only way to handle this is likely to delete the file containing the database (as the data is unreadable without the key). On the bright side, for sync users it should all be pulled down on the next sync. + +- `NoSuchRecord`, `InvalidRecord` all indicate problems with either your code or the arguments given to various functions. You may trigger and handle these if you like (it may be more convenient in some scenarios), but code that wishes to completely avoid them should be able to. + +The errors reported as "raw" `LoginsApiException` are things like Rust panics, errors reported by OpenSSL or SQLcipher, corrupt data on the server (things that are not JSON after decryption), bugs in our code, etc. You don't need to handle these, and it would likely be beneficial (but of course not necessary) to report them via some sort of telemetry, if any is available. + +### Can I use an in-memory SQLcipher connection with `DatabaseLoginsStorage`? + +Just create a `DatabaseLoginsStorage` with the path `:memory:`, and it will work. You may also use a [SQLite URI filename](https://www.sqlite.org/uri.html) with the parameter `mode=memory`. See https://www.sqlite.org/inmemorydb.html for more options and further information. + +### What's `wipeLocal`? + +`wipeLocal` deletes all local data from the database, bringing us back to the state prior to the first local write (or sync). That is, it returns it to an empty database. + +## 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/sync-logins/build.gradle b/mobile/android/android-components/components/service/sync-logins/build.gradle new file mode 100644 index 0000000000..6a80afa113 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/build.gradle @@ -0,0 +1,53 @@ +/* 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 + } + + lint { + warningsAsErrors true + abortOnError true + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + consumerProguardFiles 'proguard-rules-consumer.pro' + } + } + + namespace 'mozilla.components.browser.storage.sync.logins' +} + +dependencies { + // Parts of this dependency are typealiase'd or are otherwise part of this module's public API. + api (ComponentsDependencies.mozilla_appservices_logins) { + // Use our own version of the Glean dependency, + // which might be different from the version declared by A-S. + exclude group: 'org.mozilla.components', module: 'service-glean' + } + api ComponentsDependencies.mozilla_appservices_sync15 + + // Types defined in concept-sync are part of this module's public API. + api project(':concept-sync') + api project(':lib-dataprotect') + + implementation project(':concept-storage') + implementation project(':support-utils') + implementation project(':service-glean') + + implementation ComponentsDependencies.kotlin_coroutines +} + +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/sync-logins/proguard-rules-consumer.pro b/mobile/android/android-components/components/service/sync-logins/proguard-rules-consumer.pro new file mode 100644 index 0000000000..d3456cd17e --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/proguard-rules-consumer.pro @@ -0,0 +1 @@ +# ProGuard rules for consumers of this library. diff --git a/mobile/android/android-components/components/service/sync-logins/proguard-rules.pro b/mobile/android/android-components/components/service/sync-logins/proguard-rules.pro new file mode 100644 index 0000000000..50e2b38a97 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/proguard-rules.pro @@ -0,0 +1,25 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /Users/sebastian/Library/Android/sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# 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/sync-logins/src/main/AndroidManifest.xml b/mobile/android/android-components/components/service/sync-logins/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..816719811c --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- 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/sync-logins/src/main/java/mozilla/components/service/sync/logins/DefaultLoginValidationDelegate.kt b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/DefaultLoginValidationDelegate.kt new file mode 100644 index 0000000000..96baa7d646 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/DefaultLoginValidationDelegate.kt @@ -0,0 +1,46 @@ +/* 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.sync.logins + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.async +import mozilla.components.concept.base.crash.CrashReporting +import mozilla.components.concept.storage.Login +import mozilla.components.concept.storage.LoginEntry +import mozilla.components.concept.storage.LoginValidationDelegate +import mozilla.components.concept.storage.LoginValidationDelegate.Result +import mozilla.components.concept.storage.LoginsStorage +import mozilla.components.support.base.log.logger.Logger + +/** + * A delegate that will check against [storage] to see if a given Login can be persisted, and return + * information about why it can or cannot. + */ +class DefaultLoginValidationDelegate( + private val storage: Lazy<LoginsStorage>, + private val scope: CoroutineScope = CoroutineScope(IO), + private val crashReporting: CrashReporting? = null, +) : LoginValidationDelegate { + private val logger = Logger("DefaultAddonUpdater") + + /** + * Compares a [Login] to a passed in list of potential dupes [Login] or queries underlying + * storage for potential dupes list of [Login] to determine if it should be updated or created. + */ + override fun shouldUpdateOrCreateAsync(entry: LoginEntry): Deferred<Result> { + return scope.async { + val foundLogin = try { + storage.value.findLoginToUpdate(entry) + } catch (e: LoginsApiException) { + logger.warn("Failure in shouldUpdateOrCreateAsync: $e") + crashReporting?.submitCaughtException(e) + null + } + if (foundLogin == null) Result.CanBeCreated else Result.CanBeUpdated(foundLogin) + } + } +} diff --git a/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/GeckoLoginStorageDelegate.kt b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/GeckoLoginStorageDelegate.kt new file mode 100644 index 0000000000..892930e28d --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/GeckoLoginStorageDelegate.kt @@ -0,0 +1,69 @@ +/* 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.sync.logins + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import mozilla.components.concept.storage.Login +import mozilla.components.concept.storage.LoginEntry +import mozilla.components.concept.storage.LoginStorageDelegate +import mozilla.components.concept.storage.LoginsStorage + +/** + * [LoginStorageDelegate] implementation. + * + * An abstraction that handles the persistence and retrieval of [LoginEntry]s so that Gecko doesn't + * have to. + * + * In order to use this class, attach it to the active [GeckoRuntime] as its `loginStorageDelegate`. + * It is not designed to work with other engines. + * + * This class is part of a complex flow integrating Gecko and Application Services code, which is + * described here: + * + * - GV finds something on a page that it believes could be autofilled + * - GV calls [onLoginFetch] + * - We retrieve all [Login]s with matching domains (if any) from [loginStorage] + * - We return these [Login]s to GV + * - GV autofills one of the returned [Login]s into the page + * - GV calls [onLoginUsed] to let us know which [Login] was used + * - User submits their credentials + * - GV detects something that looks like a login submission + * - ([GeckoLoginStorageDelegate] is not involved with this step) + * `SaveLoginDialogFragment` is shown to the user, who decides whether or not + * to save the [LoginEntry] and gives them a chance to manually adjust the + * username/password fields. + * - `SaveLoginDialogFragment` uses `DefaultLoginValidationDelegate` to determine + * what the result of the operation will be: saving a new login, + * updating an existing login, or filling in a blank username. + * - If the user accepts: GV calls [onLoginSave] with the [LoginEntry] + */ +class GeckoLoginStorageDelegate( + private val loginStorage: Lazy<LoginsStorage>, + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), +) : LoginStorageDelegate { + + override fun onLoginUsed(login: Login) { + scope.launch { + loginStorage.value.touch(login.guid) + } + } + + override fun onLoginFetch(domain: String): Deferred<List<Login>> { + return scope.async { + loginStorage.value.getByBaseDomain(domain) + } + } + + @Synchronized + override fun onLoginSave(login: LoginEntry) { + scope.launch { + loginStorage.value.addOrUpdate(login) + } + } +} diff --git a/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/LoginsCrypto.kt b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/LoginsCrypto.kt new file mode 100644 index 0000000000..a16b45cac2 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/LoginsCrypto.kt @@ -0,0 +1,129 @@ +/* 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.sync.logins + +import android.content.Context +import android.content.SharedPreferences +import mozilla.appservices.logins.KeyRegenerationEventReason +import mozilla.appservices.logins.checkCanary +import mozilla.appservices.logins.createCanary +import mozilla.appservices.logins.decryptFields +import mozilla.appservices.logins.recordKeyRegenerationEvent +import mozilla.components.concept.storage.EncryptedLogin +import mozilla.components.concept.storage.KeyGenerationReason +import mozilla.components.concept.storage.KeyManager +import mozilla.components.concept.storage.Login +import mozilla.components.concept.storage.ManagedKey +import mozilla.components.lib.dataprotect.SecureAbove22Preferences + +/** + * A class that knows how to encrypt & decrypt strings, backed by application-services' logins lib. + * Used for protecting usernames/passwords at rest. + * + * This class manages creation and storage of the encryption key. + * It also keeps track of abnormal events, such as managed key going missing or getting corrupted. + * + * @param context [Context] used for obtaining [SharedPreferences] for managing internal prefs. + * @param securePrefs A [SecureAbove22Preferences] instance used for storing the managed key. + */ +class LoginsCrypto( + private val context: Context, + private val securePrefs: SecureAbove22Preferences, + private val storage: SyncableLoginsStorage, +) : KeyManager() { + private val plaintextPrefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } + + override suspend fun recoverFromKeyLoss(reason: KeyGenerationReason.RecoveryNeeded) { + val telemetryEventReason = when (reason) { + is KeyGenerationReason.RecoveryNeeded.Lost -> KeyRegenerationEventReason.Lost + is KeyGenerationReason.RecoveryNeeded.Corrupt -> KeyRegenerationEventReason.Corrupt + is KeyGenerationReason.RecoveryNeeded.AbnormalState -> KeyRegenerationEventReason.Other + } + recordKeyRegenerationEvent(telemetryEventReason) + storage.conn.getStorage().wipeLocal() + } + + override fun getStoredCanary(): String? { + return plaintextPrefs.getString(CANARY_PHRASE_CIPHERTEXT_KEY, null) + } + + override fun getStoredKey(): String? { + return securePrefs.getString(LOGINS_KEY) + } + + override fun storeKeyAndCanary(key: String) { + // To consider: should this be a non-destructive operation, just in case? + // e.g. if we thought we lost the key, but actually did not, that would let us recover data later on. + // otherwise, if we mess up and override a perfectly good key, the data is gone for good. + securePrefs.putString(LOGINS_KEY, key) + // To detect key corruption or absence, use the newly generated key to encrypt a known string. + // See isKeyValid below. + plaintextPrefs + .edit() + .putString(CANARY_PHRASE_CIPHERTEXT_KEY, createCanary(CANARY_PHRASE_PLAINTEXT, key)) + .apply() + } + + override fun createKey(): String { + return mozilla.appservices.logins.createKey() + } + + override fun isKeyRecoveryNeeded(rawKey: String, canary: String): KeyGenerationReason.RecoveryNeeded? { + return try { + if (checkCanary(canary, CANARY_PHRASE_PLAINTEXT, rawKey)) { + null + } else { + // A bad key should trigger a IncorrectKey, but check this branch just in case. + KeyGenerationReason.RecoveryNeeded.Corrupt + } + } catch (e: IncorrectKey) { + KeyGenerationReason.RecoveryNeeded.Corrupt + } + } + + /** + * Decrypts ciphertext fields within [login], producing a plaintext [Login]. + */ + suspend fun decryptLogin(login: EncryptedLogin): Login { + return decryptLogin(login, getOrGenerateKey()) + } + + /** + * Decrypts ciphertext fields within [login], producing a plaintext [Login]. + * + * This version inputs a ManagedKey. Use this for operations that + * decrypt multiple logins to avoid constructing the key multiple times. + */ + fun decryptLogin(login: EncryptedLogin, key: ManagedKey): Login { + val secFields = decryptFields(login.secFields, key.key) + // Note: The autofill code catches errors on decryptFields and returns + // null, but it's not as easy to recover in this case since the code + // almost certainly going to need to a [Login], so we just throw in + // that case. Decryption errors shouldn't be happen as long as the + // canary checking code below is working correctly + + return Login( + guid = login.guid, + origin = login.origin, + username = secFields.username, + password = secFields.password, + formActionOrigin = login.formActionOrigin, + httpRealm = login.httpRealm, + usernameField = login.usernameField, + passwordField = login.passwordField, + timesUsed = login.timesUsed, + timeCreated = login.timeCreated, + timeLastUsed = login.timeLastUsed, + timePasswordChanged = login.timePasswordChanged, + ) + } + + companion object { + const val PREFS_NAME = "loginsCrypto" + const val LOGINS_KEY = "loginsKey" + const val CANARY_PHRASE_CIPHERTEXT_KEY = "canaryPhrase" + const val CANARY_PHRASE_PLAINTEXT = "a string for checking validity of the key" + } +} diff --git a/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/SyncableLoginsStorage.kt b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/SyncableLoginsStorage.kt new file mode 100644 index 0000000000..8eafd25aab --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/SyncableLoginsStorage.kt @@ -0,0 +1,280 @@ +/* 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.sync.logins + +import android.content.Context +import androidx.annotation.GuardedBy +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import mozilla.appservices.logins.DatabaseLoginsStorage +import mozilla.components.concept.storage.EncryptedLogin +import mozilla.components.concept.storage.Login +import mozilla.components.concept.storage.LoginEntry +import mozilla.components.concept.storage.LoginsStorage +import mozilla.components.concept.sync.SyncableStore +import mozilla.components.lib.dataprotect.SecureAbove22Preferences +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.utils.logElapsedTime +import java.io.Closeable + +// Current database +const val DB_NAME = "logins2.sqlite" + +// Name of our preferences file +const val PREFS_NAME = "logins" + +// SQLCipher migration status. +// - 0 / unset: We haven't done the SQLCipher migration +// - 1: We performed v1 of the SQLCipher migration +// +// We no longer migrate SQLCipher, if the user hasn't +// successfully migrated - we delete the DB +const val SQL_CIPHER_MIGRATION = "sql-cipher-migration" + +/** + * The telemetry ping from a successful sync + */ +typealias SyncTelemetryPing = mozilla.appservices.sync15.SyncTelemetryPing + +/** + * The base class of all errors emitted by logins storage. + * + * Concrete instances of this class are thrown for operations which are + * not expected to be handled in a meaningful way by the application. + * + * For example, caught Rust panics, SQL errors, failure to generate secure + * random numbers, etc. are all examples of things which will result in a + * concrete `LoginsApiException`. + */ +typealias LoginsApiException = mozilla.appservices.logins.LoginsApiException + +/** + * This indicates that the authentication information (e.g. the [SyncUnlockInfo]) + * provided to [AsyncLoginsStorage.sync] is invalid. This often indicates that it's + * stale and should be refreshed with FxA (however, care should be taken not to + * get into a loop refreshing this information). + */ +typealias SyncAuthInvalidException = mozilla.appservices.logins.LoginsApiException.SyncAuthInvalid + +/** + * This is thrown if `update()` is performed with a record whose GUID + * does not exist. + */ +typealias NoSuchRecordException = mozilla.appservices.logins.LoginsApiException.NoSuchRecord + +/** + * This is thrown on attempts to insert or update a record so that it + * is no longer valid, where "invalid" is defined as such: + * + * - A record with a blank `password` is invalid. + * - A record with a blank `hostname` is invalid. + * - A record that doesn't have a `formSubmitURL` nor a `httpRealm` is invalid. + * - A record that has both a `formSubmitURL` and a `httpRealm` is invalid. + */ +typealias InvalidRecordException = mozilla.appservices.logins.LoginsApiException.InvalidRecord + +/** + * Error encrypting/decrypting logins data + */ +typealias IncorrectKey = mozilla.appservices.logins.LoginsApiException.IncorrectKey + +/** + * Implements [LoginsStorage] and [SyncableStore] using the application-services logins library. + * + * Synchronization is handled via the SyncManager by calling [registerWithSyncManager] + */ +class SyncableLoginsStorage( + private val context: Context, + private val securePrefs: Lazy<SecureAbove22Preferences>, +) : LoginsStorage, SyncableStore, AutoCloseable { + private val plaintextPrefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } + private val logger = Logger("SyncableLoginsStorage") + private val coroutineContext by lazy { Dispatchers.IO } + val crypto by lazy { LoginsCrypto(context, securePrefs.value, this) } + + internal val conn by lazy { + // We do not migrate SQLCipher anymore, we should delete it if it exists + runBlocking(coroutineContext) { + deleteSQLCipherDBIfNeeded() + } + LoginStorageConnection.init(dbPath = context.getDatabasePath(DB_NAME).absolutePath) + LoginStorageConnection + } + + /** + * "Warms up" this storage layer by establishing the database connection. + */ + suspend fun warmUp() = withContext(coroutineContext) { + logElapsedTime(logger, "Warming up storage") { conn } + Unit + } + + /** + * @throws [LoginsApiException] if the storage is locked, and on unexpected + * errors (IO failure, rust panics, etc) + */ + @Throws(LoginsApiException::class) + override suspend fun wipeLocal() = withContext(coroutineContext) { + conn.getStorage().wipeLocal() + } + + /** + * @throws [LoginsApiException] if the storage is locked, and on unexpected + * errors (IO failure, rust panics, etc) + */ + @Throws(LoginsApiException::class) + override suspend fun delete(guid: String): Boolean = withContext(coroutineContext) { + conn.getStorage().delete(guid) + } + + /** + * @throws [LoginsApiException] if the storage is locked, and on unexpected + * errors (IO failure, rust panics, etc) + */ + @Throws(LoginsApiException::class) + override suspend fun get(guid: String): Login? = withContext(coroutineContext) { + conn.getStorage().get(guid)?.toEncryptedLogin()?.let { crypto.decryptLogin(it) } + } + + /** + * @throws [NoSuchRecordException] if the login does not exist. + * @throws [LoginsApiException] if the storage is locked, and on unexpected + * errors (IO failure, rust panics, etc) + */ + @Throws(NoSuchRecordException::class, LoginsApiException::class) + override suspend fun touch(guid: String) = withContext(coroutineContext) { + conn.getStorage().touch(guid) + } + + /** + * @throws [LoginsApiException] if the storage is locked, and on unexpected + * errors (IO failure, rust panics, etc) + */ + @Throws(LoginsApiException::class) + override suspend fun list(): List<Login> = withContext(coroutineContext) { + val key = crypto.getOrGenerateKey() + conn.getStorage().list().map { crypto.decryptLogin(it.toEncryptedLogin(), key) } + } + + /** + * @throws [InvalidRecordException] if the record is invalid. + * @throws [IncorrectKey] if the encryption key can't decrypt the login + * @throws [LoginsApiException] if the storage is locked, and on unexpected + * errors (IO failure, rust panics, etc) + */ + @Throws(IncorrectKey::class, InvalidRecordException::class, LoginsApiException::class) + override suspend fun add(entry: LoginEntry) = withContext(coroutineContext) { + conn.getStorage().add(entry.toLoginEntry(), crypto.getOrGenerateKey().key).toEncryptedLogin() + } + + /** + * @throws [NoSuchRecordException] if the login does not exist. + * @throws [IncorrectKey] if the encryption key can't decrypt the login + * @throws [InvalidRecordException] if the update would create an invalid record. + * @throws [LoginsApiException] if the storage is locked, and on unexpected + * errors (IO failure, rust panics, etc) + */ + @Throws( + IncorrectKey::class, + NoSuchRecordException::class, + InvalidRecordException::class, + LoginsApiException::class, + ) + override suspend fun update(guid: String, entry: LoginEntry) = withContext(coroutineContext) { + conn.getStorage().update(guid, entry.toLoginEntry(), crypto.getOrGenerateKey().key).toEncryptedLogin() + } + + /** + * @throws [InvalidRecordException] if the update would create an invalid record. + * @throws [IncorrectKey] if the encryption key can't decrypt the login + * @throws [LoginsApiException] if the storage is locked, and on unexpected + * errors (IO failure, rust panics, etc) + */ + @Throws(IncorrectKey::class, InvalidRecordException::class, LoginsApiException::class) + override suspend fun addOrUpdate(entry: LoginEntry) = withContext(coroutineContext) { + conn.getStorage().addOrUpdate(entry.toLoginEntry(), crypto.getOrGenerateKey().key).toEncryptedLogin() + } + + override fun registerWithSyncManager() { + conn.getStorage().registerWithSyncManager() + } + + /** + * @throws [LoginsApiException] On unexpected errors (IO failure, rust panics, etc) + */ + @Throws(LoginsApiException::class) + override suspend fun getByBaseDomain(origin: String): List<Login> = withContext(coroutineContext) { + val key = crypto.getOrGenerateKey() + conn.getStorage().getByBaseDomain(origin).map { crypto.decryptLogin(it.toEncryptedLogin(), key) } + } + + /** + * @throws [IncorrectKey] if the encryption key can't decrypt the login + * @throws [LoginsApiException] On unexpected errors (IO failure, rust panics, etc) + */ + @Throws(LoginsApiException::class) + override suspend fun findLoginToUpdate(entry: LoginEntry): Login? = withContext(coroutineContext) { + conn.getStorage().findLoginToUpdate(entry.toLoginEntry(), crypto.getOrGenerateKey().key)?.toLogin() + } + + /** + * @throws [IncorrectKey] if the encryption key can't decrypt the login + */ + override suspend fun decryptLogin(login: EncryptedLogin) = crypto.decryptLogin(login) + + override fun close() { + coroutineContext.cancel() + conn.close() + } + + /* + * We not longer migrate SQLCipher, we delete the DB, key and any + * associated prefs + */ + private suspend fun deleteSQLCipherDBIfNeeded() { + // Older database that was encrypted using SQLCipher + val dbNameSqlCipher = "logins.sqlite" + // Prefs key that we stored the old SQLCipher encryption key + val encryptionKeySqlCipher = "passwords" + + val version = plaintextPrefs.getInt(SQL_CIPHER_MIGRATION, 0) + if (version == 0) { + // Older database that was encrypted using SQLCipher, majority of clients won't + // have anything to actually delete + securePrefs.value.remove(encryptionKeySqlCipher) + val file = context.getDatabasePath(dbNameSqlCipher) + file.delete() + } + plaintextPrefs.edit().putInt(SQL_CIPHER_MIGRATION, 1).apply() + } +} + +/** + * A singleton wrapping a [LoginsStorage] connection. + */ +internal object LoginStorageConnection : Closeable { + @GuardedBy("this") + private var storage: DatabaseLoginsStorage? = null + + internal fun init(dbPath: String = DB_NAME) = synchronized(this) { + if (storage == null) { + storage = DatabaseLoginsStorage(dbPath) + } + storage + } + + internal fun getStorage(): DatabaseLoginsStorage = synchronized(this) { + check(storage != null) { "must call init first" } + return storage!! + } + + override fun close() = synchronized(this) { + check(storage != null) { "must call init first" } + storage!!.close() + storage = null + } +} diff --git a/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/Types.kt b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/Types.kt new file mode 100644 index 0000000000..7141610251 --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/src/main/java/mozilla/components/service/sync/logins/Types.kt @@ -0,0 +1,87 @@ +/* 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.sync.logins + +import mozilla.components.concept.storage.EncryptedLogin +import mozilla.components.concept.storage.Login +import mozilla.components.concept.storage.LoginEntry + +// Convert between application-services data classes and the ones in concept.storage. + +/** + * Convert A-S EncryptedLogin into A-C [EncryptedLogin]. + */ +fun mozilla.appservices.logins.EncryptedLogin.toEncryptedLogin() = EncryptedLogin( + guid = record.id, + origin = fields.origin, + formActionOrigin = fields.formActionOrigin, + httpRealm = fields.httpRealm, + usernameField = fields.usernameField, + passwordField = fields.passwordField, + timesUsed = record.timesUsed, + timeCreated = record.timeCreated, + timeLastUsed = record.timeLastUsed, + timePasswordChanged = record.timePasswordChanged, + secFields = secFields, +) + +/** + * Convert A-S Login into A-C [Login]. + */ +fun mozilla.appservices.logins.Login.toLogin() = Login( + guid = record.id, + origin = fields.origin, + username = secFields.username, + password = secFields.password, + formActionOrigin = fields.formActionOrigin, + httpRealm = fields.httpRealm, + usernameField = fields.usernameField, + passwordField = fields.passwordField, + timesUsed = record.timesUsed, + timeCreated = record.timeCreated, + timeLastUsed = record.timeLastUsed, + timePasswordChanged = record.timePasswordChanged, +) + +/** + * Convert A-C [LoginEntry] into A-S LoginEntry. + */ +fun LoginEntry.toLoginEntry() = mozilla.appservices.logins.LoginEntry( + fields = mozilla.appservices.logins.LoginFields( + origin = origin, + formActionOrigin = formActionOrigin, + httpRealm = httpRealm, + usernameField = usernameField, + passwordField = passwordField, + ), + secFields = mozilla.appservices.logins.SecureLoginFields( + username = username, + password = password, + ), +) + +/** + * Convert A-C [Login] into A-S Login. + */ +fun Login.toLogin() = mozilla.appservices.logins.Login( + record = mozilla.appservices.logins.RecordFields( + id = guid, + timesUsed = timesUsed, + timeCreated = timeCreated, + timeLastUsed = timeLastUsed, + timePasswordChanged = timePasswordChanged, + ), + fields = mozilla.appservices.logins.LoginFields( + origin = origin, + formActionOrigin = formActionOrigin, + httpRealm = httpRealm, + usernameField = usernameField, + passwordField = passwordField, + ), + secFields = mozilla.appservices.logins.SecureLoginFields( + username = username, + password = password, + ), +) diff --git a/mobile/android/android-components/components/service/sync-logins/src/test/resources/robolectric.properties b/mobile/android/android-components/components/service/sync-logins/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/service/sync-logins/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 |