diff --git a/components/feature/addons/src/main/java/mozilla/components/feature/addons/amo/AddonCollectionProvider.kt b/components/feature/addons/src/main/java/mozilla/components/feature/addons/amo/AddonCollectionProvider.kt index 2b35ff82377..03a02831baa 100644 --- a/components/feature/addons/src/main/java/mozilla/components/feature/addons/amo/AddonCollectionProvider.kt +++ b/components/feature/addons/src/main/java/mozilla/components/feature/addons/amo/AddonCollectionProvider.kt @@ -17,6 +17,7 @@ import mozilla.components.concept.fetch.isSuccess import mozilla.components.feature.addons.Addon import mozilla.components.feature.addons.AddonsProvider import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.ktx.kotlin.sanitizeFileName import mozilla.components.support.ktx.kotlin.sanitizeURL import mozilla.components.support.ktx.util.readAndDeserialize import mozilla.components.support.ktx.util.writeString @@ -32,6 +33,7 @@ import java.util.Locale internal const val API_VERSION = "api/v4" internal const val DEFAULT_SERVER_URL = "https://addons.mozilla.org" +internal const val DEFAULT_COLLECTION_USER = "mozilla" internal const val DEFAULT_COLLECTION_NAME = "7e8d6dc651b54ab385fb8791bf9dac" internal const val COLLECTION_FILE_NAME = "mozilla_components_addon_collection_%s.json" internal const val MINUTE_IN_MS = 60 * 1000 @@ -41,19 +43,24 @@ internal const val DEFAULT_READ_TIMEOUT_IN_SECONDS = 20L * Provide access to the AMO collections API. * https://addons-server.readthedocs.io/en/latest/topics/api/collections.html * + * @property context A reference to the application context. + * @property client A [Client] for interacting with the AMO HTTP api. * @property serverURL The url of the endpoint to interact with e.g production, staging * or testing. Defaults to [DEFAULT_SERVER_URL]. + * @property collectionUser The id or name of the user owning the collection specified in + * [collectionName], defaults to [DEFAULT_COLLECTION_USER]. * @property collectionName The name of the collection to access, defaults * to [DEFAULT_COLLECTION_NAME]. * @property maxCacheAgeInMinutes maximum time (in minutes) the collection cache * should remain valid before a refresh is attempted. Defaults to -1, meaning no - * cache is being used by default. - * @property client A reference of [Client] for interacting with the AMO HTTP api. + * cache is being used by default */ +@Suppress("LongParameterList") class AddonCollectionProvider( private val context: Context, private val client: Client, private val serverURL: String = DEFAULT_SERVER_URL, + private val collectionUser: String = DEFAULT_COLLECTION_USER, private val collectionName: String = DEFAULT_COLLECTION_NAME, private val maxCacheAgeInMinutes: Long = -1 ) : AddonsProvider { @@ -113,7 +120,7 @@ class AddonCollectionProvider( private fun fetchAvailableAddons(readTimeoutInSeconds: Long?): List { client.fetch( Request( - url = "$serverURL/$API_VERSION/accounts/account/mozilla/collections/$collectionName/addons", + url = "$serverURL/$API_VERSION/accounts/account/$collectionUser/collections/$collectionName/addons", readTimeout = Pair(readTimeoutInSeconds ?: DEFAULT_READ_TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) ) ) @@ -192,7 +199,25 @@ class AddonCollectionProvider( } private fun getBaseCacheFile(context: Context): File { - return File(context.filesDir, COLLECTION_FILE_NAME.format(collectionName)) + return File(context.filesDir, getCacheFileName()) + } + + @VisibleForTesting + internal fun getCacheFileName(): String { + val collectionUser = collectionUser.sanitizeFileName() + val collectionName = collectionName.sanitizeFileName() + + // Prefix with collection user in case it was customized. We don't want + // to do this for the default "mozilla" user so we don't break out of + // the existing cache when we're introducing this. Plus mozilla is + // already in the file name anyway. + val collection = if (collectionUser != DEFAULT_COLLECTION_USER) { + "${collectionUser}_$collectionName" + } else { + collectionName + } + + return COLLECTION_FILE_NAME.format(collection).sanitizeFileName() } } diff --git a/components/feature/addons/src/test/java/AddonCollectionProviderTest.kt b/components/feature/addons/src/test/java/AddonCollectionProviderTest.kt index 103b3049f5b..115fee70f55 100644 --- a/components/feature/addons/src/test/java/AddonCollectionProviderTest.kt +++ b/components/feature/addons/src/test/java/AddonCollectionProviderTest.kt @@ -36,185 +36,136 @@ import java.util.concurrent.TimeUnit class AddonCollectionProviderTest { @Test - fun `getAvailableAddons - with a successful status response must contain add-ons`() { - val jsonResponse = loadResourceAsString("/collection.json") - val mockedClient = mock() - val mockedResponse = mock() - val mockedBody = mock() - whenever(mockedBody.string(any())).thenReturn(jsonResponse) - whenever(mockedResponse.body).thenReturn(mockedBody) - whenever(mockedResponse.status).thenReturn(200) - whenever(mockedClient.fetch(any())).thenReturn(mockedResponse) - + fun `getAvailableAddons - with a successful status response must contain add-ons`() = runBlocking { + val mockedClient = prepareClient(loadResourceAsString("/collection.json")) val provider = AddonCollectionProvider(testContext, client = mockedClient) + val addons = provider.getAvailableAddons() + val addon = addons.first() + + assertTrue(addons.isNotEmpty()) + + // Add-on details + assertEquals("uBlock0@raymondhill.net", addon.id) + assertEquals("2015-04-25T07:26:22Z", addon.createdAt) + assertEquals("2019-10-24T09:28:41Z", addon.updatedAt) + assertEquals( + "https://addons.cdn.mozilla.net/user-media/addon_icons/607/607454-64.png?modified=mcrushed", + addon.iconUrl + ) + assertEquals( + "https://addons.mozilla.org/en-US/firefox/addon/ublock-origin/", + addon.siteUrl + ) + assertEquals( + "https://addons.mozilla.org/firefox/downloads/file/3428595/ublock_origin-1.23.0-an+fx.xpi?src=", + addon.downloadUrl + ) + assertEquals( + "menus", + addon.permissions.first() + ) + assertEquals( + "uBlock Origin", + addon.translatableName["ca"] + ) + assertEquals( + "Finalment, un blocador eficient que utilitza pocs recursos de memòria i processador.", + addon.translatableSummary["ca"] + ) + assertTrue(addon.translatableDescription.getValue("ca").isNotBlank()) + assertEquals("1.23.0", addon.version) + assertEquals("es", addon.defaultLocale) + + // Authors + assertEquals("11423598", addon.authors.first().id) + assertEquals("Raymond Hill", addon.authors.first().name) + assertEquals("gorhill", addon.authors.first().username) + assertEquals( + "https://addons.mozilla.org/en-US/firefox/user/11423598/", + addon.authors.first().url + ) - runBlocking { - val addons = provider.getAvailableAddons() - val addon = addons.first() - - assertTrue(addons.isNotEmpty()) - - // Add-on details - assertEquals("uBlock0@raymondhill.net", addon.id) - assertEquals("2015-04-25T07:26:22Z", addon.createdAt) - assertEquals("2019-10-24T09:28:41Z", addon.updatedAt) - assertEquals( - "https://addons.cdn.mozilla.net/user-media/addon_icons/607/607454-64.png?modified=mcrushed", - addon.iconUrl - ) - assertEquals( - "https://addons.mozilla.org/en-US/firefox/addon/ublock-origin/", - addon.siteUrl - ) - assertEquals( - "https://addons.mozilla.org/firefox/downloads/file/3428595/ublock_origin-1.23.0-an+fx.xpi?src=", - addon.downloadUrl - ) - assertEquals( - "menus", - addon.permissions.first() - ) - assertEquals( - "uBlock Origin", - addon.translatableName["ca"] - ) - assertEquals( - "Finalment, un blocador eficient que utilitza pocs recursos de memòria i processador.", - addon.translatableSummary["ca"] - ) - assertTrue(addon.translatableDescription.getValue("ca").isNotBlank()) - assertEquals("1.23.0", addon.version) - assertEquals("es", addon.defaultLocale) - - // Authors - assertEquals("11423598", addon.authors.first().id) - assertEquals("Raymond Hill", addon.authors.first().name) - assertEquals("gorhill", addon.authors.first().username) - assertEquals( - "https://addons.mozilla.org/en-US/firefox/user/11423598/", - addon.authors.first().url - ) - - // Ratings - assertEquals(4.7003F, addon.rating!!.average, 0.7003F) - assertEquals(9930, addon.rating!!.reviews) - } + // Ratings + assertEquals(4.7003F, addon.rating!!.average, 0.7003F) + assertEquals(9930, addon.rating!!.reviews) } @Test - fun `getAvailableAddons - with a successful status response must handle empty values`() { - val jsonResponse = loadResourceAsString("/collection_with_empty_values.json") - val mockedClient = mock() - val mockedResponse = mock() - val mockedMockedBody = mock() - - whenever(mockedMockedBody.string(any())).thenReturn(jsonResponse) - whenever(mockedResponse.body).thenReturn(mockedMockedBody) - whenever(mockedResponse.status).thenReturn(200) - whenever(mockedClient.fetch(any())).thenReturn(mockedResponse) - - val provider = AddonCollectionProvider(testContext, client = mockedClient) - - runBlocking { - val addons = provider.getAvailableAddons() - val addon = addons.first() - - assertTrue(addons.isNotEmpty()) - - // Add-on - assertEquals("", addon.id) - assertEquals("", addon.createdAt) - assertEquals("", addon.updatedAt) - assertEquals("", addon.iconUrl) - assertEquals("", addon.siteUrl) - assertEquals("", addon.version) - assertEquals("", addon.downloadUrl) - assertTrue(addon.permissions.isEmpty()) - assertTrue(addon.translatableName.isEmpty()) - assertTrue(addon.translatableSummary.isEmpty()) - assertEquals("", addon.translatableDescription.getValue("ca")) - assertEquals(Addon.DEFAULT_LOCALE, addon.defaultLocale) - - // Authors - assertTrue(addon.authors.isEmpty()) - verify(mockedClient).fetch(Request( - url = "https://addons.mozilla.org/api/v4/accounts/account/mozilla/collections/7e8d6dc651b54ab385fb8791bf9dac/addons", - readTimeout = Pair(DEFAULT_READ_TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) - )) - - // Ratings - assertNull(addon.rating) - } + fun `getAvailableAddons - with a successful status response must handle empty values`() = runBlocking { + val client = prepareClient() + val provider = AddonCollectionProvider(testContext, client = client) + + val addons = provider.getAvailableAddons() + val addon = addons.first() + + assertTrue(addons.isNotEmpty()) + + // Add-on + assertEquals("", addon.id) + assertEquals("", addon.createdAt) + assertEquals("", addon.updatedAt) + assertEquals("", addon.iconUrl) + assertEquals("", addon.siteUrl) + assertEquals("", addon.version) + assertEquals("", addon.downloadUrl) + assertTrue(addon.permissions.isEmpty()) + assertTrue(addon.translatableName.isEmpty()) + assertTrue(addon.translatableSummary.isEmpty()) + assertEquals("", addon.translatableDescription.getValue("ca")) + assertEquals(Addon.DEFAULT_LOCALE, addon.defaultLocale) + + // Authors + assertTrue(addon.authors.isEmpty()) + verify(client).fetch(Request( + url = "https://addons.mozilla.org/api/v4/accounts/account/mozilla/collections/7e8d6dc651b54ab385fb8791bf9dac/addons", + readTimeout = Pair(DEFAULT_READ_TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) + )) + + // Ratings + assertNull(addon.rating) } @Test - fun `getAvailableAddons - read timeout can be configured`() { - val jsonResponse = loadResourceAsString("/collection_with_empty_values.json") - val mockedClient = mock() - val mockedResponse = mock() - val mockedBody = mock() - whenever(mockedBody.string(any())).thenReturn(jsonResponse) - whenever(mockedResponse.body).thenReturn(mockedBody) - whenever(mockedResponse.status).thenReturn(200) - whenever(mockedClient.fetch(any())).thenReturn(mockedResponse) + fun `getAvailableAddons - read timeout can be configured`() = runBlocking { + val mockedClient = prepareClient() val provider = spy(AddonCollectionProvider(testContext, client = mockedClient)) - - runBlocking { - provider.getAvailableAddons(readTimeoutInSeconds = 5) - verify(mockedClient).fetch(Request( - url = "https://addons.mozilla.org/api/v4/accounts/account/mozilla/collections/7e8d6dc651b54ab385fb8791bf9dac/addons", - readTimeout = Pair(5, TimeUnit.SECONDS) - )) - } + provider.getAvailableAddons(readTimeoutInSeconds = 5) + verify(mockedClient).fetch(Request( + url = "https://addons.mozilla.org/api/v4/accounts/account/mozilla/collections/7e8d6dc651b54ab385fb8791bf9dac/addons", + readTimeout = Pair(5, TimeUnit.SECONDS) + )) + Unit } @Test(expected = IOException::class) - fun `getAvailableAddons - with unexpected status will throw exception`() { - val mockedClient = mock() - val mockedResponse = mock() - val mockedMockedBody = mock() - - whenever(mockedResponse.body).thenReturn(mockedMockedBody) - whenever(mockedResponse.status).thenReturn(500) - whenever(mockedClient.fetch(any())).thenReturn(mockedResponse) - + fun `getAvailableAddons - with unexpected status will throw exception`() = runBlocking { + val mockedClient = prepareClient(status = 500) val provider = AddonCollectionProvider(testContext, client = mockedClient) - - runBlocking { - provider.getAvailableAddons() - } + provider.getAvailableAddons() + Unit } @Test - fun `getAvailableAddons - returns cached result if allowed and not expired`() { - val jsonResponse = loadResourceAsString("/collection.json") - val mockedClient = mock() - val mockedResponse = mock() - val mockedBody = mock() - whenever(mockedBody.string(any())).thenReturn(jsonResponse) - whenever(mockedResponse.body).thenReturn(mockedBody) - whenever(mockedResponse.status).thenReturn(200) - whenever(mockedClient.fetch(any())).thenReturn(mockedResponse) + fun `getAvailableAddons - returns cached result if allowed and not expired`() = runBlocking { + val mockedClient = prepareClient(loadResourceAsString("/collection.json")) val provider = spy(AddonCollectionProvider(testContext, client = mockedClient)) + provider.getAvailableAddons(false) + verify(provider, never()).readFromDiskCache() - runBlocking { - provider.getAvailableAddons(false) - verify(provider, never()).readFromDiskCache() - - whenever(provider.cacheExpired(testContext)).thenReturn(true) - provider.getAvailableAddons(true) - verify(provider, never()).readFromDiskCache() + whenever(provider.cacheExpired(testContext)).thenReturn(true) + provider.getAvailableAddons(true) + verify(provider, never()).readFromDiskCache() - whenever(provider.cacheExpired(testContext)).thenReturn(false) - provider.getAvailableAddons(true) - verify(provider).readFromDiskCache() - } + whenever(provider.cacheExpired(testContext)).thenReturn(false) + provider.getAvailableAddons(true) + verify(provider).readFromDiskCache() + Unit } @Test - fun `getAvailableAddons - returns cached result if allowed and fetch failed`() { + fun `getAvailableAddons - returns cached result if allowed and fetch failed`() = runBlocking { val mockedClient: Client = mock() val exception = IOException("test") val cachedAddons: List = emptyList() @@ -222,60 +173,50 @@ class AddonCollectionProviderTest { val provider = spy(AddonCollectionProvider(testContext, client = mockedClient)) - runBlocking { - try { - // allowCache = false - provider.getAvailableAddons(allowCache = false) - fail("Expected IOException") - } catch (e: IOException) { - assertSame(exception, e) - } - - try { - // allowCache = true, but no cache present - provider.getAvailableAddons(allowCache = true) - fail("Expected IOException") - } catch (e: IOException) { - assertSame(exception, e) - } - - try { - // allowCache = true, cache present, but we fail to read - whenever(provider.getCacheLastUpdated(testContext)).thenReturn(Date().time) - provider.getAvailableAddons(allowCache = true) - fail("Expected IOException") - } catch (e: IOException) { - assertSame(exception, e) - } - - // allowCache = true, cache present, and reading successfully + try { + // allowCache = false + provider.getAvailableAddons(allowCache = false) + fail("Expected IOException") + } catch (e: IOException) { + assertSame(exception, e) + } + + try { + // allowCache = true, but no cache present + provider.getAvailableAddons(allowCache = true) + fail("Expected IOException") + } catch (e: IOException) { + assertSame(exception, e) + } + + try { + // allowCache = true, cache present, but we fail to read whenever(provider.getCacheLastUpdated(testContext)).thenReturn(Date().time) - whenever(provider.readFromDiskCache()).thenReturn(cachedAddons) - assertSame(cachedAddons, provider.getAvailableAddons(allowCache = true)) + provider.getAvailableAddons(allowCache = true) + fail("Expected IOException") + } catch (e: IOException) { + assertSame(exception, e) } + + // allowCache = true, cache present, and reading successfully + whenever(provider.getCacheLastUpdated(testContext)).thenReturn(Date().time) + whenever(provider.readFromDiskCache()).thenReturn(cachedAddons) + assertSame(cachedAddons, provider.getAvailableAddons(allowCache = true)) } @Test - fun `getAvailableAddons - writes response to cache if configured`() { + fun `getAvailableAddons - writes response to cache if configured`() = runBlocking { val jsonResponse = loadResourceAsString("/collection.json") - val mockedClient = mock() - val mockedResponse = mock() - val mockedBody = mock() - whenever(mockedBody.string(any())).thenReturn(jsonResponse) - whenever(mockedResponse.body).thenReturn(mockedBody) - whenever(mockedResponse.status).thenReturn(200) - whenever(mockedClient.fetch(any())).thenReturn(mockedResponse) + val mockedClient = prepareClient(jsonResponse) val provider = spy(AddonCollectionProvider(testContext, client = mockedClient)) val cachingProvider = spy(AddonCollectionProvider(testContext, client = mockedClient, maxCacheAgeInMinutes = 1)) - runBlocking { - provider.getAvailableAddons() - verify(provider, never()).writeToDiskCache(jsonResponse) + provider.getAvailableAddons() + verify(provider, never()).writeToDiskCache(jsonResponse) - cachingProvider.getAvailableAddons() - verify(cachingProvider).writeToDiskCache(jsonResponse) - } + cachingProvider.getAvailableAddons() + verify(cachingProvider).writeToDiskCache(jsonResponse) } @Test @@ -299,7 +240,7 @@ class AddonCollectionProviderTest { } @Test - fun `getAddonIconBitmap - with a successful status will return a bitmap`() { + fun `getAddonIconBitmap - with a successful status will return a bitmap`() = runBlocking { val mockedClient = mock() val mockedResponse = mock() val stream: InputStream = javaClass.getResourceAsStream("/png/mozac.png")!!.buffered() @@ -311,47 +252,130 @@ class AddonCollectionProviderTest { val provider = AddonCollectionProvider(testContext, client = mockedClient) val addon = Addon( - id = "id", - authors = mock(), - categories = mock(), - downloadUrl = "https://example.com", - version = "version", - iconUrl = "https://example.com/image.png", - createdAt = "0", - updatedAt = "0" + id = "id", + authors = mock(), + categories = mock(), + downloadUrl = "https://example.com", + version = "version", + iconUrl = "https://example.com/image.png", + createdAt = "0", + updatedAt = "0" ) - runBlocking { - val bitmap = provider.getAddonIconBitmap(addon) - assertTrue(bitmap is Bitmap) - } + val bitmap = provider.getAddonIconBitmap(addon) + assertTrue(bitmap is Bitmap) } @Test - fun `getAddonIconBitmap - with an unsuccessful status will return null`() { - val mockedClient = mock() - val mockedResponse = mock() - val mockedMockedBody = mock() - - whenever(mockedResponse.body).thenReturn(mockedMockedBody) - whenever(mockedResponse.status).thenReturn(500) - whenever(mockedClient.fetch(any())).thenReturn(mockedResponse) - + fun `getAddonIconBitmap - with an unsuccessful status will return null`() = runBlocking { + val mockedClient = prepareClient(status = 500) val provider = AddonCollectionProvider(testContext, client = mockedClient) val addon = Addon( - id = "id", - authors = mock(), - categories = mock(), - downloadUrl = "https://example.com", - version = "version", - iconUrl = "https://example.com/image.png", - createdAt = "0", - updatedAt = "0" + id = "id", + authors = mock(), + categories = mock(), + downloadUrl = "https://example.com", + version = "version", + iconUrl = "https://example.com/image.png", + createdAt = "0", + updatedAt = "0" ) - runBlocking { - val bitmap = provider.getAddonIconBitmap(addon) - assertNull(bitmap) - } + val bitmap = provider.getAddonIconBitmap(addon) + assertNull(bitmap) + } + + @Test + fun `collection name can be configured`() = runBlocking { + val mockedClient = prepareClient() + + val collectionName = "collection123" + val provider = AddonCollectionProvider( + testContext, + client = mockedClient, + collectionName = collectionName + ) + + provider.getAvailableAddons() + verify(mockedClient).fetch(Request( + url = "https://addons.mozilla.org/api/v4/accounts/account/mozilla/collections/$collectionName/addons", + readTimeout = Pair(DEFAULT_READ_TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) + )) + + assertEquals(COLLECTION_FILE_NAME.format(collectionName), provider.getCacheFileName()) + } + + @Test + fun `collection user can be configured`() = runBlocking { + val mockedClient = prepareClient() + val collectionUser = "user123" + val collectionName = "collection123" + val provider = AddonCollectionProvider( + testContext, + client = mockedClient, + collectionUser = collectionUser, + collectionName = collectionName) + + provider.getAvailableAddons() + verify(mockedClient).fetch(Request( + url = "https://addons.mozilla.org/api/v4/accounts/account/" + + "$collectionUser/collections/$collectionName/addons", + readTimeout = Pair(DEFAULT_READ_TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) + )) + + assertEquals( + COLLECTION_FILE_NAME.format("${collectionUser}_$collectionName"), + provider.getCacheFileName() + ) + } + + @Test + fun `default collection is used if not configured`() = runBlocking { + val mockedClient = prepareClient() + + val provider = AddonCollectionProvider( + testContext, + client = mockedClient + ) + + provider.getAvailableAddons() + verify(mockedClient).fetch(Request( + url = "https://addons.mozilla.org/api/v4/accounts/account/" + + "$DEFAULT_COLLECTION_USER/collections/$DEFAULT_COLLECTION_NAME/addons", + readTimeout = Pair(DEFAULT_READ_TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) + )) + + assertEquals(COLLECTION_FILE_NAME.format(DEFAULT_COLLECTION_NAME), provider.getCacheFileName()) + } + + @Test + fun `cache file name is sanitized`() = runBlocking { + val mockedClient = prepareClient() + val collectionUser = "../../user" + val collectionName = "../collection" + val provider = AddonCollectionProvider( + testContext, + client = mockedClient, + collectionUser = collectionUser, + collectionName = collectionName) + + assertEquals( + COLLECTION_FILE_NAME.format("user_collection"), + provider.getCacheFileName() + ) + } + + private fun prepareClient( + jsonResponse: String = loadResourceAsString("/collection_with_empty_values.json"), + status: Int = 200 + ): Client { + val mockedClient = mock() + val mockedResponse = mock() + val mockedBody = mock() + whenever(mockedBody.string(any())).thenReturn(jsonResponse) + whenever(mockedResponse.body).thenReturn(mockedBody) + whenever(mockedResponse.status).thenReturn(status) + whenever(mockedClient.fetch(any())).thenReturn(mockedResponse) + return mockedClient } } diff --git a/docs/changelog.md b/docs/changelog.md index c782b3e65f7..7d0ec977840 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -18,8 +18,20 @@ permalink: /changelog/ * Added `TabsUseCases.UndoTabRemovalUseCase` for undoing the removal of tabs. * **feature-webcompat-reporter** * Added the ability to automatically add a screenshot as well as more technical details when submitting a WebCompat report. -* **feature-addons** +* **feature-addons** + * ⚠️ This is a breaking change for call sites that don't rely on named arguments: `AddonCollectionProvider` now supports configuring a custom collection owner (via AMO user ID or name). + ```kotlin + val addonCollectionProvider by lazy { + AddonCollectionProvider( + applicationContext, + client, + collectionUser = "16314372" + collectionName = "myCollection", + maxCacheAgeInMinutes = DAY_IN_MINUTES + ) + } * 🚒 Bug fixed [issue #8267](https://github.com/mozilla-mobile/android-components/issues/8267) Devtools permission had wrong translation. + ``` # 60.0.0