+ Panyungsi keur leumpang dina modeu luring na jeung teu tiasa nyambung ka barang anu dipénta.
Ieu parangkat nyambung ka jaringan aktip heunteu?
Pencét "Cobi deui" pikeun ngalih ka modeu daring sareng ngamuat deui ieu kaca.
@@ -125,7 +125,7 @@
Port diwates pikeun kaamanan
- Alamat anu dipénta nyebutkeun port (contona mozilla.org:80 pikeun port 80 di mozilla.org) galibna dipaké lain pikeun nyungsi Raramat. Panyungsi geus ngabolaykeun paménta pikeun kaamanan anjeun.]]>
+ Alamat anu dipénta nyebutkeun port (contona mozilla.org:80 pikeun port 80 di mozilla.org) umumna dipaké lain pikeun nyungsi Raramat. Panyungsi geus ngabolaykeun paménta pikeun kaamanan anjeun.]]>Sambunganana dirését
@@ -206,7 +206,7 @@
]]>
- Protokol Teu Dipikawanoh
+ Protokol Teu DipiwanohAlamatna méré protocol (e.g., wxyz://) anu teu dipikawanoh ku pamaluruh, ku kituna pamaluruhna teu bisa nyambung kalawan bener ka lokana.
@@ -273,7 +273,7 @@
Situs Aman Teu Sayaga
- %1$s teu sayaga.]]>
+ %1$s teu sayaga.]]>Teruskeun ka Situs HTTP
diff --git a/mobile/android/android-components/components/browser/errorpages/src/main/res/values-sv-rSE/strings.xml b/mobile/android/android-components/components/browser/errorpages/src/main/res/values-sv-rSE/strings.xml
index 9a0bf69388..4f22ac030e 100644
--- a/mobile/android/android-components/components/browser/errorpages/src/main/res/values-sv-rSE/strings.xml
+++ b/mobile/android/android-components/components/browser/errorpages/src/main/res/values-sv-rSE/strings.xml
@@ -241,7 +241,7 @@
Kan objektet ha bytt namn, tagits bort eller flyttat?
Finns det stavfel, stor bokstav eller annat typografiskt fel i adressen?
-
Har du tillräckliga åtkomstbehörigheter till den begärda objektet?
+
Har du tillräckliga åtkomstbehörigheter till det begärda objektet?
]]>
diff --git a/mobile/android/android-components/components/browser/icons/src/main/assets/extensions/browser-icons/icons.js b/mobile/android/android-components/components/browser/icons/src/main/assets/extensions/browser-icons/icons.js
index 20eada9a19..bc2c6ee35e 100644
--- a/mobile/android/android-components/components/browser/icons/src/main/assets/extensions/browser-icons/icons.js
+++ b/mobile/android/android-components/components/browser/icons/src/main/assets/extensions/browser-icons/icons.js
@@ -2,80 +2,81 @@
* 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 web extension looks for known icon tags, collects URLs and available
- * meta data (e.g. sizes) and passes that to the app code.
- */
+/*
+ * This web extension looks for known icon tags, collects URLs and available
+ * meta data (e.g. sizes) and passes that to the app code.
+ */
/**
* Takes a DOMTokenList and returns a String array.
*/
function sizesToList(sizes) {
- if (sizes == null) {
- return []
- }
+ if (sizes == null) {
+ return [];
+ }
- if (!(sizes instanceof DOMTokenList)) {
- return []
- }
+ if (!(sizes instanceof DOMTokenList)) {
+ return [];
+ }
- return Array.from(sizes)
+ return Array.from(sizes);
}
function collect_link_icons(icons, rel) {
- document.querySelectorAll('link[rel="' + rel + '"]').forEach(
- function(currentValue, currentIndex, listObj) {
- icons.push({
- 'type': rel,
- 'href': currentValue.href,
- 'sizes': sizesToList(currentValue.sizes),
- 'mimeType': currentValue.type
- });
- })
+ document
+ .querySelectorAll('link[rel="' + rel + '"]')
+ .forEach(function (currentValue, currentIndex, listObj) {
+ icons.push({
+ type: rel,
+ href: currentValue.href,
+ sizes: sizesToList(currentValue.sizes),
+ mimeType: currentValue.type,
+ });
+ });
}
function collect_meta_property_icons(icons, property) {
- document.querySelectorAll('meta[property="' + property + '"]').forEach(
- function(currentValue, currentIndex, listObj) {
- icons.push({
- 'type': property,
- 'href': currentValue.content
- })
- }
- )
+ document
+ .querySelectorAll('meta[property="' + property + '"]')
+ .forEach(function (currentValue, currentIndex, listObj) {
+ icons.push({
+ type: property,
+ href: currentValue.content,
+ });
+ });
}
function collect_meta_name_icons(icons, name) {
- document.querySelectorAll('meta[name="' + name + '"]').forEach(
- function(currentValue, currentIndex, listObj) {
- icons.push({
- 'type': name,
- 'href': currentValue.content
- })
- }
- )
+ document
+ .querySelectorAll('meta[name="' + name + '"]')
+ .forEach(function (currentValue, currentIndex, listObj) {
+ icons.push({
+ type: name,
+ href: currentValue.content,
+ });
+ });
}
let icons = [];
-collect_link_icons(icons, 'icon');
-collect_link_icons(icons, 'shortcut icon');
-collect_link_icons(icons, 'fluid-icon')
-collect_link_icons(icons, 'apple-touch-icon')
-collect_link_icons(icons, 'image_src')
-collect_link_icons(icons, 'apple-touch-icon image_src')
-collect_link_icons(icons, 'apple-touch-icon-precomposed')
+collect_link_icons(icons, "icon");
+collect_link_icons(icons, "shortcut icon");
+collect_link_icons(icons, "fluid-icon");
+collect_link_icons(icons, "apple-touch-icon");
+collect_link_icons(icons, "image_src");
+collect_link_icons(icons, "apple-touch-icon image_src");
+collect_link_icons(icons, "apple-touch-icon-precomposed");
-collect_meta_property_icons(icons, 'og:image')
-collect_meta_property_icons(icons, 'og:image:url')
-collect_meta_property_icons(icons, 'og:image:secure_url')
+collect_meta_property_icons(icons, "og:image");
+collect_meta_property_icons(icons, "og:image:url");
+collect_meta_property_icons(icons, "og:image:secure_url");
-collect_meta_name_icons(icons, 'twitter:image')
-collect_meta_name_icons(icons, 'msapplication-TileImage')
+collect_meta_name_icons(icons, "twitter:image");
+collect_meta_name_icons(icons, "msapplication-TileImage");
let message = {
- 'url': document.location.href,
- 'icons': icons
-}
+ url: document.location.href,
+ icons: icons,
+};
browser.runtime.sendNativeMessage("MozacBrowserIcons", message);
diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/BrowserIcons.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/BrowserIcons.kt
index 3bf5c2b2ff..fe44cfc6be 100644
--- a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/BrowserIcons.kt
+++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/BrowserIcons.kt
@@ -38,10 +38,12 @@ import mozilla.components.browser.icons.extension.IconMessageHandler
import mozilla.components.browser.icons.generator.DefaultIconGenerator
import mozilla.components.browser.icons.generator.IconGenerator
import mozilla.components.browser.icons.loader.DataUriIconLoader
+import mozilla.components.browser.icons.loader.DefaultMemoryInfoProvider
import mozilla.components.browser.icons.loader.DiskIconLoader
import mozilla.components.browser.icons.loader.HttpIconLoader
import mozilla.components.browser.icons.loader.IconLoader
import mozilla.components.browser.icons.loader.MemoryIconLoader
+import mozilla.components.browser.icons.loader.MemoryInfoProvider
import mozilla.components.browser.icons.loader.NonBlockingHttpIconLoader
import mozilla.components.browser.icons.pipeline.IconResourceComparator
import mozilla.components.browser.icons.preparer.DiskIconPreparer
@@ -91,6 +93,7 @@ class BrowserIcons constructor(
private val context: Context,
httpClient: Client,
private val generator: IconGenerator = DefaultIconGenerator(),
+ private val memoryInfoProvider: MemoryInfoProvider = DefaultMemoryInfoProvider(context),
private val preparers: List = listOf(
TippyTopIconPreparer(context.assets),
MemoryIconPreparer(sharedMemoryCache),
@@ -99,7 +102,10 @@ class BrowserIcons constructor(
internal var loaders: List = listOf(
MemoryIconLoader(sharedMemoryCache),
DiskIconLoader(sharedDiskCache),
- HttpIconLoader(httpClient),
+ HttpIconLoader(
+ httpClient = httpClient,
+ memoryInfoProvider = memoryInfoProvider,
+ ),
DataUriIconLoader(),
),
private val decoders: List = listOf(
@@ -120,7 +126,10 @@ class BrowserIcons constructor(
private val maximumSize = context.resources.getDimensionPixelSize(R.dimen.mozac_browser_icons_maximum_size)
private val minimumSize = context.resources.getDimensionPixelSize(R.dimen.mozac_browser_icons_minimum_size)
private val scope = CoroutineScope(jobDispatcher)
- private val backgroundHttpIconLoader = NonBlockingHttpIconLoader(httpClient) { request, resource, result ->
+ private val backgroundHttpIconLoader = NonBlockingHttpIconLoader(
+ httpClient = httpClient,
+ memoryInfoProvider = DefaultMemoryInfoProvider(context),
+ ) { request, resource, result ->
val desiredSize = request.getDesiredSize(context, minimumSize, maximumSize)
val icon = decodeIconLoaderResult(result, decoders, desiredSize)
diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/HttpIconLoader.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/HttpIconLoader.kt
index 430f46f3ec..d3217bc4b2 100644
--- a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/HttpIconLoader.kt
+++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/HttpIconLoader.kt
@@ -12,26 +12,30 @@ import androidx.core.net.toUri
import mozilla.components.browser.icons.Icon
import mozilla.components.browser.icons.IconRequest
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.support.base.log.logger.Logger
import mozilla.components.support.ktx.android.net.isHttpOrHttps
import mozilla.components.support.ktx.kotlin.sanitizeURL
+import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.concurrent.TimeUnit
private const val CONNECT_TIMEOUT = 2L // Seconds
private const val READ_TIMEOUT = 10L // Seconds
+private const val MAX_DOWNLOAD_BYTES = 1048576 // 1MB
/**
* [IconLoader] implementation that will try to download the icon for resources that point to an http(s) URL.
*/
open class HttpIconLoader(
private val httpClient: Client,
+ private val memoryInfoProvider: MemoryInfoProvider,
) : IconLoader {
- private val logger = Logger("HttpIconLoader")
private val failureCache = FailureCache()
+ private val logger = Logger("HttpIconLoader")
override fun load(context: Context, request: IconRequest, resource: IconRequest.Resource): IconLoader.Result {
if (!shouldDownload(resource)) {
@@ -78,10 +82,45 @@ open class HttpIconLoader(
protected fun shouldDownload(resource: IconRequest.Resource): Boolean {
return resource.url.sanitizeURL().toUri().isHttpOrHttps && !failureCache.hasFailedRecently(resource.url)
}
-}
-private fun Response.toIconLoaderResult() = body.useStream {
- IconLoader.Result.BytesResult(it.readBytes(), Icon.Source.DOWNLOAD)
+ private fun Response.toIconLoaderResult(): IconLoader.Result {
+ // Compare the Response Content-Length header with the available memory on device
+ val contentLengthHeader = headers[Headers.Names.CONTENT_LENGTH]
+ if (!contentLengthHeader.isNullOrEmpty()) {
+ val contentLength = contentLengthHeader.toLong()
+ return if (contentLength > MAX_DOWNLOAD_BYTES || contentLength > memoryInfoProvider.getAvailMem()) {
+ IconLoader.Result.NoResult
+ } else {
+ // Load the icon without reading to buffers since the checks above passed
+ body.useStream {
+ IconLoader.Result.BytesResult(it.readBytes(), Icon.Source.DOWNLOAD)
+ }
+ }
+ } else {
+ // Read the response body in chunks and check with available memory to prevent exceeding it
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ return ByteArrayOutputStream().use { outStream ->
+ body.useStream { inputStream ->
+ var bytesRead = 0
+ var bytesInChunk: Int
+
+ while (inputStream.read(buffer).also { bytesInChunk = it } != -1) {
+ outStream.write(buffer, 0, bytesInChunk)
+ bytesRead += bytesInChunk
+
+ if (bytesRead > MAX_DOWNLOAD_BYTES || bytesRead > memoryInfoProvider.getAvailMem()) {
+ return@useStream IconLoader.Result.NoResult
+ }
+
+ if (bytesInChunk < DEFAULT_BUFFER_SIZE) {
+ break
+ }
+ }
+ IconLoader.Result.BytesResult(outStream.toByteArray(), Icon.Source.DOWNLOAD)
+ }
+ }
+ }
+ }
}
private const val MAX_FAILURE_URLS = 25
diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/MemoryInfoProvider.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/MemoryInfoProvider.kt
new file mode 100644
index 0000000000..52f8bcbb28
--- /dev/null
+++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/MemoryInfoProvider.kt
@@ -0,0 +1,32 @@
+/* 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.browser.icons.loader
+
+import android.app.ActivityManager
+import android.content.Context
+import androidx.core.content.ContextCompat
+
+/**
+ * This class provides information about the device memory info without exposing the android
+ * framework APIs directly, making it easier to test the code that depends on it.
+ */
+interface MemoryInfoProvider {
+ /**
+ * Returns the device's available memory
+ */
+ fun getAvailMem(): Long
+}
+
+/**
+ * This class retrieves the available memory on device using activity manager.
+ */
+class DefaultMemoryInfoProvider(private val context: Context) : MemoryInfoProvider {
+ override fun getAvailMem(): Long {
+ val activityManager = ContextCompat.getSystemService(context, ActivityManager::class.java)
+ val memoryInfo = ActivityManager.MemoryInfo()
+ activityManager?.getMemoryInfo(memoryInfo)
+ return memoryInfo.availMem
+ }
+}
diff --git a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/NonBlockingHttpIconLoader.kt b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/NonBlockingHttpIconLoader.kt
index c3203dc13f..fcd64662d6 100644
--- a/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/NonBlockingHttpIconLoader.kt
+++ b/mobile/android/android-components/components/browser/icons/src/main/java/mozilla/components/browser/icons/loader/NonBlockingHttpIconLoader.kt
@@ -24,9 +24,10 @@ import mozilla.components.concept.fetch.Client
*/
class NonBlockingHttpIconLoader(
httpClient: Client,
+ memoryInfoProvider: MemoryInfoProvider,
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO),
private val loadCallback: (IconRequest, IconRequest.Resource, IconLoader.Result) -> Unit,
-) : HttpIconLoader(httpClient) {
+) : HttpIconLoader(httpClient, memoryInfoProvider) {
override fun load(context: Context, request: IconRequest, resource: IconRequest.Resource): IconLoader.Result {
if (!shouldDownload(resource)) {
return IconLoader.Result.NoResult
diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/BrowserIconsTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/BrowserIconsTest.kt
index 67a392d0a2..b14b5a30b3 100644
--- a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/BrowserIconsTest.kt
+++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/BrowserIconsTest.kt
@@ -13,6 +13,7 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import mozilla.components.browser.icons.generator.IconGenerator
+import mozilla.components.browser.icons.loader.MemoryInfoProvider
import mozilla.components.concept.engine.manifest.Size
import mozilla.components.lib.fetch.httpurlconnection.HttpURLConnectionClient
import mozilla.components.support.test.any
@@ -46,6 +47,11 @@ import java.io.OutputStream
@ExperimentalCoroutinesApi // for runTestOnMain
@RunWith(AndroidJUnit4::class)
class BrowserIconsTest {
+ private val defaultAvailMem: Long = 100000
+
+ class FakeMemoryInfoProvider(private val availMem: Long) : MemoryInfoProvider {
+ override fun getAvailMem(): Long = availMem
+ }
@get:Rule
val coroutinesTestRule = MainCoroutineRule()
@@ -65,7 +71,12 @@ class BrowserIconsTest {
`when`(generator.generate(any(), any())).thenReturn(mockedIcon)
val request = IconRequest(url = "https://www.mozilla_test.org")
- val icon = BrowserIcons(testContext, httpClient = mock(), generator = generator)
+ val icon = BrowserIcons(
+ context = testContext,
+ httpClient = mock(),
+ generator = generator,
+ memoryInfoProvider = FakeMemoryInfoProvider(defaultAvailMem),
+ )
.loadIcon(request)
assertEquals(mockedIcon, icon.await())
@@ -114,6 +125,7 @@ class BrowserIconsTest {
val icon = BrowserIcons(
testContext,
httpClient = HttpURLConnectionClient(),
+ memoryInfoProvider = FakeMemoryInfoProvider(defaultAvailMem),
).loadIcon(request).await()
assertNotNull(icon)
@@ -139,7 +151,11 @@ class BrowserIconsTest {
server.start()
try {
- val icons = BrowserIcons(testContext, httpClient = HttpURLConnectionClient())
+ val icons = BrowserIcons(
+ context = testContext,
+ httpClient = HttpURLConnectionClient(),
+ memoryInfoProvider = FakeMemoryInfoProvider(defaultAvailMem),
+ )
val request = IconRequest(
url = "https://www.mozilla.org",
@@ -182,7 +198,11 @@ class BrowserIconsTest {
server.start()
try {
- val icons = BrowserIcons(testContext, httpClient = HttpURLConnectionClient())
+ val icons = BrowserIcons(
+ context = testContext,
+ httpClient = HttpURLConnectionClient(),
+ memoryInfoProvider = FakeMemoryInfoProvider(defaultAvailMem),
+ )
val request = IconRequest(
url = "https://www.mozilla.org",
diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/HttpIconLoaderTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/HttpIconLoaderTest.kt
index 3670066921..872440900a 100644
--- a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/HttpIconLoaderTest.kt
+++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/HttpIconLoaderTest.kt
@@ -34,6 +34,11 @@ import java.io.InputStream
@RunWith(AndroidJUnit4::class)
class HttpIconLoaderTest {
+ private val defaultAvailMem: Long = 100000
+
+ class FakeMemoryInfoProvider(private val availMem: Long) : MemoryInfoProvider {
+ override fun getAvailMem(): Long = availMem
+ }
@Test
fun `Loader downloads data and uses appropriate headers`() {
@@ -56,7 +61,7 @@ class HttpIconLoaderTest {
server.start()
try {
- val loader = HttpIconLoader(client)
+ val loader = HttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem))
val result = loader.load(
mock(),
mock(),
@@ -94,7 +99,7 @@ class HttpIconLoaderTest {
fun `Loader will not perform any requests for data uris`() {
val client: Client = mock()
- val result = HttpIconLoader(client).load(
+ val result = HttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem)).load(
mock(),
mock(),
IconRequest.Resource(
@@ -112,7 +117,7 @@ class HttpIconLoaderTest {
fun `Request has timeouts applied`() {
val client: Client = mock()
- val loader = HttpIconLoader(client)
+ val loader = HttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem))
doReturn(
Response(
url = "https://www.example.org",
@@ -144,7 +149,7 @@ class HttpIconLoaderTest {
fun `NoResult is returned for non-successful requests`() {
val client: Client = mock()
- val loader = HttpIconLoader(client)
+ val loader = HttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem))
doReturn(
Response(
url = "https://www.example.org",
@@ -170,7 +175,7 @@ class HttpIconLoaderTest {
fun `Loader will not try to load URL again that just recently failed`() {
val client: Client = mock()
- val loader = HttpIconLoader(client)
+ val loader = HttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem))
doReturn(
Response(
url = "https://www.example.org",
@@ -203,7 +208,7 @@ class HttpIconLoaderTest {
val client: Client = mock()
doThrow(IOException("Mock")).`when`(client).fetch(any())
- val loader = HttpIconLoader(client)
+ val loader = HttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem))
val resource = IconRequest.Resource(
url = "https://www.example.org",
@@ -223,7 +228,7 @@ class HttpIconLoaderTest {
}
}
- val loader = HttpIconLoader(client)
+ val loader = HttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem))
doReturn(
Response(
url = "https://www.example.org",
@@ -241,11 +246,165 @@ class HttpIconLoaderTest {
assertEquals(IconLoader.Result.NoResult, loader.load(mock(), mock(), resource))
}
+ @Test
+ fun `Loader will return NoResult for response with large Content-Length size`() {
+ val clients = listOf(
+ HttpURLConnectionClient(),
+ OkHttpClient(),
+ )
+
+ clients.forEach { client ->
+ val server = MockWebServer()
+
+ // Create a mock Response object with the Content-Length header set to a large size
+ server.enqueue(
+ MockResponse().setBody(
+ javaClass.getResourceAsStream("/misc/test.txt")!!
+ .bufferedReader()
+ .use { it.readText() },
+ ).addHeader("Content-Length", "2048576"),
+ )
+
+ server.start()
+
+ try {
+ val loader = HttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem))
+ val result = loader.load(
+ mock(),
+ mock(),
+ IconRequest.Resource(
+ url = server.url("/some/path").toString(),
+ type = IconRequest.Resource.Type.APPLE_TOUCH_ICON,
+ ),
+ )
+
+ assertTrue(result is IconLoader.Result.NoResult)
+ } finally {
+ server.shutdown()
+ }
+ }
+ }
+
+ @Test
+ fun `Loader will return NoResult for valid Content-Length size and low available memory`() {
+ val clients = listOf(
+ HttpURLConnectionClient(),
+ OkHttpClient(),
+ )
+
+ clients.forEach { client ->
+ val server = MockWebServer()
+
+ server.enqueue(
+ MockResponse().setBody(
+ javaClass.getResourceAsStream("/misc/test.txt")!!
+ .bufferedReader()
+ .use { it.readText() },
+ ).addHeader("Content-Length", "10000"),
+ )
+
+ server.start()
+
+ try {
+ val loader = HttpIconLoader(client, FakeMemoryInfoProvider(availMem = 0))
+ val result = loader.load(
+ mock(),
+ mock(),
+ IconRequest.Resource(
+ url = server.url("/some/path").toString(),
+ type = IconRequest.Resource.Type.APPLE_TOUCH_ICON,
+ ),
+ )
+
+ assertTrue(result is IconLoader.Result.NoResult)
+ } finally {
+ server.shutdown()
+ }
+ }
+ }
+
+ @Test
+ fun `Loader will return NoResult for null Content-Length header and low available memory`() {
+ val clients = listOf(
+ HttpURLConnectionClient(),
+ OkHttpClient(),
+ )
+
+ clients.forEach { client ->
+ val server = MockWebServer()
+
+ server.enqueue(
+ MockResponse().setBody(
+ javaClass.getResourceAsStream("/misc/test.txt")!!
+ .bufferedReader()
+ .use { it.readText() },
+ ).removeHeader("Content-Length"),
+ )
+
+ server.start()
+
+ try {
+ val loader = HttpIconLoader(client, FakeMemoryInfoProvider(availMem = 0))
+ val result = loader.load(
+ mock(),
+ mock(),
+ IconRequest.Resource(
+ url = server.url("/some/path").toString(),
+ type = IconRequest.Resource.Type.APPLE_TOUCH_ICON,
+ ),
+ )
+
+ assertTrue(result is IconLoader.Result.NoResult)
+ } finally {
+ server.shutdown()
+ }
+ }
+ }
+
+ @Test
+ fun `Loader downloads data for null Content-Length header and response size within limits`() {
+ val clients = listOf(
+ HttpURLConnectionClient(),
+ OkHttpClient(),
+ )
+
+ clients.forEach { client ->
+ val server = MockWebServer()
+
+ server.enqueue(
+ MockResponse().setBody(
+ javaClass.getResourceAsStream("/misc/test.txt")!!
+ .bufferedReader()
+ .use { it.readText() },
+ ).removeHeader("Content-Length"),
+ )
+
+ server.start()
+
+ try {
+ val loader = HttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem))
+ val result = loader.load(
+ mock(),
+ mock(),
+ IconRequest.Resource(
+ url = server.url("/some/path").toString(),
+ type = IconRequest.Resource.Type.APPLE_TOUCH_ICON,
+ ),
+ )
+ assertTrue("Result should match BytesResult", result is IconLoader.Result.BytesResult)
+ val data = (result as IconLoader.Result.BytesResult).bytes
+ assertTrue("Data should not be empty", data.isNotEmpty())
+ } finally {
+ server.shutdown()
+ }
+ }
+ }
+
@Test
fun `Loader will sanitize URL`() {
val client: Client = mock()
- val loader = HttpIconLoader(client)
+ val loader = HttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem))
doReturn(
Response(
url = "https://www.example.org",
diff --git a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/NonBlockingHttpIconLoaderTest.kt b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/NonBlockingHttpIconLoaderTest.kt
index 6b2fe8d8ad..2a2da044bf 100644
--- a/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/NonBlockingHttpIconLoaderTest.kt
+++ b/mobile/android/android-components/components/browser/icons/src/test/java/mozilla/components/browser/icons/loader/NonBlockingHttpIconLoaderTest.kt
@@ -45,6 +45,11 @@ class NonBlockingHttpIconLoaderTest {
@get:Rule
val coroutinesTestRule = MainCoroutineRule()
private val scope = coroutinesTestRule.scope
+ private val defaultAvailMem: Long = 100000
+
+ class FakeMemoryInfoProvider(private val availMem: Long) : MemoryInfoProvider {
+ override fun getAvailMem(): Long = availMem
+ }
@Test
fun `Loader will return IconLoader#Result#NoResult for a load request and respond with the result through a callback`() = runTestOnMain {
@@ -71,7 +76,7 @@ class NonBlockingHttpIconLoaderTest {
var callbackIconRequest: IconRequest? = null
var callbackResource: IconRequest.Resource? = null
var callbackIcon: IconLoader.Result? = null
- val loader = NonBlockingHttpIconLoader(client, scope) { request, resource, icon ->
+ val loader = NonBlockingHttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem), scope) { request, resource, icon ->
callbackIconRequest = request
callbackResource = resource
callbackIcon = icon
@@ -106,7 +111,7 @@ class NonBlockingHttpIconLoaderTest {
var callbackIconRequest: IconRequest? = null
var callbackResource: IconRequest.Resource? = null
var callbackIcon: IconLoader.Result? = null
- val loader = NonBlockingHttpIconLoader(client, scope) { request, resource, icon ->
+ val loader = NonBlockingHttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem), scope) { request, resource, icon ->
callbackIconRequest = request
callbackResource = resource
callbackIcon = icon
@@ -132,7 +137,7 @@ class NonBlockingHttpIconLoaderTest {
@Test
fun `Request has timeouts applied`() = runTestOnMain {
val client: Client = mock()
- val loader = NonBlockingHttpIconLoader(client, scope) { _, _, _ -> }
+ val loader = NonBlockingHttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem), scope) { _, _, _ -> }
doReturn(
Response(
url = "https://www.example.org",
@@ -165,7 +170,7 @@ class NonBlockingHttpIconLoaderTest {
var callbackIconRequest: IconRequest? = null
var callbackResource: IconRequest.Resource? = null
var callbackIcon: IconLoader.Result? = null
- val loader = NonBlockingHttpIconLoader(client, scope) { request, resource, icon ->
+ val loader = NonBlockingHttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem), scope) { request, resource, icon ->
callbackIconRequest = request
callbackResource = resource
callbackIcon = icon
@@ -198,7 +203,7 @@ class NonBlockingHttpIconLoaderTest {
@Test
fun `Loader will not try to load URL again that just recently failed`() = runTestOnMain {
val client: Client = mock()
- val loader = NonBlockingHttpIconLoader(client, scope) { _, _, _ -> }
+ val loader = NonBlockingHttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem), scope) { _, _, _ -> }
doReturn(
Response(
url = "https://www.example.org",
@@ -231,7 +236,7 @@ class NonBlockingHttpIconLoaderTest {
var callbackIconRequest: IconRequest? = null
var callbackResource: IconRequest.Resource? = null
var callbackIcon: IconLoader.Result? = null
- val loader = NonBlockingHttpIconLoader(client, scope) { request, resource, icon ->
+ val loader = NonBlockingHttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem), scope) { request, resource, icon ->
callbackIconRequest = request
callbackResource = resource
callbackIcon = icon
@@ -256,7 +261,7 @@ class NonBlockingHttpIconLoaderTest {
var callbackIconRequest: IconRequest? = null
var callbackResource: IconRequest.Resource? = null
var callbackIcon: IconLoader.Result? = null
- val loader = NonBlockingHttpIconLoader(client, scope) { request, resource, icon ->
+ val loader = NonBlockingHttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem), scope) { request, resource, icon ->
callbackIconRequest = request
callbackResource = resource
callbackIcon = icon
@@ -292,7 +297,7 @@ class NonBlockingHttpIconLoaderTest {
fun `Loader will sanitize URL`() = runTestOnMain {
val client: Client = mock()
val captor = argumentCaptor()
- val loader = NonBlockingHttpIconLoader(client, scope) { _, _, _ -> }
+ val loader = NonBlockingHttpIconLoader(client, FakeMemoryInfoProvider(defaultAvailMem), scope) { _, _, _ -> }
doReturn(
Response(
url = "https://www.example.org",
diff --git a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/CustomTooltip.kt b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/CustomTooltip.kt
index 9e7ce8b674..6f83c1ffd9 100644
--- a/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/CustomTooltip.kt
+++ b/mobile/android/android-components/components/browser/menu/src/main/java/mozilla/components/browser/menu/item/CustomTooltip.kt
@@ -14,7 +14,6 @@ import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.PopupWindow
import android.widget.TextView
-import androidx.core.view.ViewCompat
import androidx.core.widget.PopupWindowCompat
import mozilla.components.browser.menu.R
@@ -34,7 +33,7 @@ internal class CustomTooltip private constructor(
}
override fun onLongClick(view: View): Boolean {
- if (ViewCompat.isAttachedToWindow(anchor)) {
+ if (anchor.isAttachedToWindow()) {
show()
anchor.addOnAttachStateChangeListener(this)
}
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-be/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-be/strings.xml
index f3b3bb1755..9edc4724a9 100644
--- a/mobile/android/android-components/components/browser/menu/src/main/res/values-be/strings.xml
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-be/strings.xml
@@ -10,6 +10,12 @@
ПашырэнніМенеджар дадаткаў
+
+ Менеджар пашырэнняўПерайсці ўверх
-
+
+ Дадаткі, перайсці ўніз
+
+ Пашырэнні, перайсці ўверх
+
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-br/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-br/strings.xml
index 548cbd7785..f8e14c773d 100644
--- a/mobile/android/android-components/components/browser/menu/src/main/res/values-br/strings.xml
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-br/strings.xml
@@ -14,4 +14,8 @@
Merañ an askouezhioùAdpignat
-
+
+ Enlugelladoù, pignat
+
+ Askouezhioù, pignat
+
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-cak/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-cak/strings.xml
index 7ccb1be616..7623a1f39f 100644
--- a/mobile/android/android-components/components/browser/menu/src/main/res/values-cak/strings.xml
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-cak/strings.xml
@@ -5,11 +5,17 @@
Ya\'on ruq\'ij
- Taq tz\'aqat
+ Taq tz\'aqat
+
+ Taq k\'amal
- Kinuk\'samajel taq Tz\'aqat
+ Kinuk\'samajel taq Tz\'aqat
+
+ Runuk\'samajel taq K\'amalTib\'an okem ajsik
- Taq tz\'aqat, tijote\' chi rokem
-
+ Taq tz\'aqat, tijote\' chi rokem
+
+ Taq k\'amal, tok q\'anij
+
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-eo/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-eo/strings.xml
index 63b30a2150..1ca46d5302 100644
--- a/mobile/android/android-components/components/browser/menu/src/main/res/values-eo/strings.xml
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-eo/strings.xml
@@ -5,11 +5,17 @@
Elstarigitaj
- Aldonaĵoj
+ Aldonaĵoj
+
+ Etendaĵoj
- Administrilo de aldonaĵoj
+ Administrilo de aldonaĵoj
+
+ Administranto de etendaĵojIri supren
- Aldonaĵoj, supren
-
+ Aldonaĵoj, supren
+
+ Etendaĵoj, supren
+
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-eu/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-eu/strings.xml
index 53f1a556a3..b69e874c01 100644
--- a/mobile/android/android-components/components/browser/menu/src/main/res/values-eu/strings.xml
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-eu/strings.xml
@@ -5,11 +5,17 @@
Nabarmendua
- Gehigarriak
+ Gehigarriak
+
+ Hedapenak
- Gehigarrien kudeatzailea
+ Gehigarrien kudeatzailea
+
+ Hedapenen kudeatzaileaNabigatu gora
- Gehigarriak, nabigatu gora
-
+ Gehigarriak, nabigatu gora
+
+ Hedapenak, nabigatu gora
+
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-kab/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-kab/strings.xml
index f36a1014b1..58d8809a2c 100644
--- a/mobile/android/android-components/components/browser/menu/src/main/res/values-kab/strings.xml
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-kab/strings.xml
@@ -10,6 +10,12 @@
IsiɣzafAmsefrak n izegrar
+
+ Amsefrak n yisiɣzafInig d asawen
-
+
+ Izegrar niḍen, ulin-d
+
+ Isiɣzaf niḍen, ulin-d
+
diff --git a/mobile/android/android-components/components/browser/menu/src/main/res/values-sc/strings.xml b/mobile/android/android-components/components/browser/menu/src/main/res/values-sc/strings.xml
index 4fd82314b3..0a94a4fc21 100644
--- a/mobile/android/android-components/components/browser/menu/src/main/res/values-sc/strings.xml
+++ b/mobile/android/android-components/components/browser/menu/src/main/res/values-sc/strings.xml
@@ -5,9 +5,15 @@
In evidèntzia
- Cumplementos
+ Cumplementos
+
+ Estensiones
- Gestore de cumplementos
+ Gestore de cumplementos
+
+ Gestore de estensionesNàviga in artu
-
+
+ Estensiones, torra a coa
+
diff --git a/mobile/android/android-components/components/browser/menu2/src/test/java/mozilla/components/browser/menu2/ext/BrowserMenuPositioningTest.kt b/mobile/android/android-components/components/browser/menu2/src/test/java/mozilla/components/browser/menu2/ext/BrowserMenuPositioningTest.kt
index d9be746793..23c421bd7c 100644
--- a/mobile/android/android-components/components/browser/menu2/src/test/java/mozilla/components/browser/menu2/ext/BrowserMenuPositioningTest.kt
+++ b/mobile/android/android-components/components/browser/menu2/src/test/java/mozilla/components/browser/menu2/ext/BrowserMenuPositioningTest.kt
@@ -7,7 +7,6 @@ package mozilla.components.browser.menu2.ext
import android.graphics.Rect
import android.view.View
import android.widget.PopupWindow
-import androidx.core.view.ViewCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.components.browser.menu2.R
@@ -755,9 +754,9 @@ internal fun createAnchor(x: Int, y: Int, isRTL: Boolean = false): View {
}.`when`(view).getLocationInWindow(any())
if (isRTL) {
- doReturn(ViewCompat.LAYOUT_DIRECTION_RTL).`when`(view).layoutDirection
+ doReturn(View.LAYOUT_DIRECTION_RTL).`when`(view).layoutDirection
} else {
- doReturn(ViewCompat.LAYOUT_DIRECTION_LTR).`when`(view).layoutDirection
+ doReturn(View.LAYOUT_DIRECTION_LTR).`when`(view).layoutDirection
}
doReturn(10).`when`(view).height
doReturn(15).`when`(view).width
diff --git a/mobile/android/android-components/components/browser/session-storage/src/androidTest/assets/index.html b/mobile/android/android-components/components/browser/session-storage/src/androidTest/assets/index.html
index b511ab2f19..199b5f61d4 100644
--- a/mobile/android/android-components/components/browser/session-storage/src/androidTest/assets/index.html
+++ b/mobile/android/android-components/components/browser/session-storage/src/androidTest/assets/index.html
@@ -1,8 +1,8 @@
-
+
Restore Test
-
-
+
+
Hello World
-
+
diff --git a/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/action/BrowserAction.kt b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/action/BrowserAction.kt
index cd492b18e5..42ad594c8f 100644
--- a/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/action/BrowserAction.kt
+++ b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/action/BrowserAction.kt
@@ -1047,6 +1047,30 @@ sealed class TranslationsAction : BrowserAction() {
val setting: Boolean,
) : TranslationsAction(), ActionWithTab
+ /**
+ * Sets the translations offer setting on the global store.
+ * The translations offer setting controls when to offer a translation on a page.
+ *
+ * See [SetPageSettingsAction] for setting the offer setting on the session store.
+ *
+ * @property offerTranslation The offer setting to set.
+ */
+ data class SetGlobalOfferTranslateSettingAction(
+ val offerTranslation: Boolean,
+ ) : TranslationsAction()
+
+ /**
+ * Updates the specified translation offer setting on the translation engine and ensures the final
+ * state on the global store remains in-sync.
+ *
+ * See [UpdatePageSettingAction] for updating the offer setting on the session store.
+ *
+ * @property offerTranslation The offer setting to set.
+ */
+ data class UpdateGlobalOfferTranslateSettingAction(
+ val offerTranslation: Boolean,
+ ) : TranslationsAction()
+
/**
* Sets the map of BCP 47 language codes (key) and the [LanguageSetting] option (value).
*
@@ -1057,27 +1081,37 @@ sealed class TranslationsAction : BrowserAction() {
val languageSettings: Map,
) : TranslationsAction()
+ /**
+ * Updates the specified translation language setting on the translation engine and ensures the
+ * final state on the global store remains in-sync.
+ *
+ * See [UpdatePageSettingAction] for updating the language setting on the session store.
+ *
+ * @property languageCode The BCP-47 language code to update.
+ * @property setting The [LanguageSetting] for the language.
+ */
+ data class UpdateLanguageSettingsAction(
+ val languageCode: String,
+ val setting: LanguageSetting,
+ ) : TranslationsAction()
+
/**
* Sets the list of sites that the user has opted to never translate.
*
- * @property tabId The ID of the tab the [EngineSession] that requested the list.
* @property neverTranslateSites The never translate sites.
*/
data class SetNeverTranslateSitesAction(
- override val tabId: String,
val neverTranslateSites: List,
- ) : TranslationsAction(), ActionWithTab
+ ) : TranslationsAction()
/**
* Remove from the list of sites the user has opted to never translate.
*
- * @property tabId The ID of the tab the [EngineSession] that requested the removal.
* @property origin A site origin URI that will have the specified never translate permission set.
*/
data class RemoveNeverTranslateSiteAction(
- override val tabId: String,
val origin: String,
- ) : TranslationsAction(), ActionWithTab
+ ) : TranslationsAction()
/**
* Sets the list of language machine learning translation models the translation engine has available.
@@ -1255,6 +1289,7 @@ sealed class EngineAction : BrowserAction() {
override val tabId: String,
val skipLoading: Boolean = false,
val followupAction: BrowserAction? = null,
+ val includeParent: Boolean = false,
) : EngineAction(), ActionWithTab
/**
@@ -1265,6 +1300,7 @@ sealed class EngineAction : BrowserAction() {
val url: String,
val flags: EngineSession.LoadUrlFlags = EngineSession.LoadUrlFlags.none(),
val additionalHeaders: Map? = null,
+ val includeParent: Boolean = false,
) : EngineAction(), ActionWithTab
/**
@@ -1402,6 +1438,7 @@ sealed class EngineAction : BrowserAction() {
val engineSession: EngineSession,
val timestamp: Long = Clock.elapsedRealtime(),
val skipLoading: Boolean = false,
+ val includeParent: Boolean = false,
) : EngineAction(), ActionWithTab
/**
diff --git a/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/CreateEngineSessionMiddleware.kt b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/CreateEngineSessionMiddleware.kt
index 6f807eff16..e83fefb9e1 100644
--- a/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/CreateEngineSessionMiddleware.kt
+++ b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/CreateEngineSessionMiddleware.kt
@@ -69,6 +69,7 @@ internal class CreateEngineSessionMiddleware(
logger,
store,
action.tabId,
+ action.includeParent,
)
action.followupAction?.let {
@@ -85,6 +86,7 @@ private fun getOrCreateEngineSession(
logger: Logger,
store: Store,
tabId: String,
+ includeParent: Boolean,
): EngineSession? {
val tab = store.state.findTabOrCustomTab(tabId)
if (tab == null) {
@@ -102,7 +104,7 @@ private fun getOrCreateEngineSession(
return it
}
- return createEngineSession(engine, logger, store, tab)
+ return createEngineSession(engine, logger, store, tab, includeParent)
}
@MainThread
@@ -111,6 +113,7 @@ private fun createEngineSession(
logger: Logger,
store: Store,
tab: SessionState,
+ includeParent: Boolean,
): EngineSession {
val engineSession = engine.createSession(tab.content.private, tab.contextId)
logger.debug("Created engine session for tab ${tab.id}")
@@ -127,6 +130,7 @@ private fun createEngineSession(
tab.id,
engineSession,
skipLoading = skipLoading,
+ includeParent = includeParent,
),
)
diff --git a/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/EngineDelegateMiddleware.kt b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/EngineDelegateMiddleware.kt
index 1b7744ff41..82298ef2ce 100644
--- a/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/EngineDelegateMiddleware.kt
+++ b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/EngineDelegateMiddleware.kt
@@ -70,11 +70,11 @@ internal class EngineDelegateMiddleware(
// session is already pointing to. Creating an EngineSession will do exactly
// that in the linking step. So let's do that. Otherwise we would load the URL
// twice.
- store.dispatch(EngineAction.CreateEngineSessionAction(action.tabId))
+ store.dispatch(EngineAction.CreateEngineSessionAction(action.tabId, includeParent = action.includeParent))
return@launch
}
- val parentEngineSession = if (tab is TabSessionState) {
+ val parentEngineSession = if (action.includeParent && tab is TabSessionState) {
tab.parentId?.let { store.state.findTabOrCustomTab(it)?.engineState?.engineSession }
} else {
null
diff --git a/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/LinkingMiddleware.kt b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/LinkingMiddleware.kt
index c6f1d01d39..ba714b5e90 100644
--- a/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/LinkingMiddleware.kt
+++ b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/LinkingMiddleware.kt
@@ -37,7 +37,13 @@ internal class LinkingMiddleware(
when (action) {
is TabListAction.AddTabAction -> {
if (action.tab.engineState.engineSession != null && action.tab.engineState.engineObserver == null) {
- engineObserver = link(context, action.tab.engineState.engineSession, action.tab)
+ engineObserver = link(
+ context,
+ action.tab.engineState.engineSession,
+ action.tab,
+ skipLoading = true,
+ includeParent = false,
+ )
}
}
is TabListAction.AddMultipleTabsAction -> {
@@ -58,7 +64,7 @@ internal class LinkingMiddleware(
when (action) {
is EngineAction.LinkEngineSessionAction -> {
context.state.findTabOrCustomTab(action.tabId)?.let { tab ->
- engineObserver = link(context, action.engineSession, tab, action.skipLoading)
+ engineObserver = link(context, action.engineSession, tab, action.skipLoading, action.includeParent)
}
}
else -> {
@@ -77,6 +83,7 @@ internal class LinkingMiddleware(
engineSession: EngineSession,
tab: SessionState,
skipLoading: Boolean = true,
+ includeParent: Boolean,
): Pair {
val observer = EngineObserver(tab.id, context.store)
engineSession.register(observer)
@@ -91,7 +98,7 @@ internal class LinkingMiddleware(
// tab, but opened by an extension e.g. via browser.tabs.update.
performLoadOnMainThread(engineSession, tab.content.url, loadFlags = tab.engineState.initialLoadFlags)
} else {
- val parentEngineSession = if (tab is TabSessionState) {
+ val parentEngineSession = if (includeParent && tab is TabSessionState) {
tab.parentId?.let { context.state.findTabOrCustomTab(it)?.engineState?.engineSession }
} else {
null
diff --git a/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/TranslationsMiddleware.kt b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/TranslationsMiddleware.kt
index 81e3b8b48b..3c8e645a97 100644
--- a/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/TranslationsMiddleware.kt
+++ b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/engine/middleware/TranslationsMiddleware.kt
@@ -79,6 +79,12 @@ class TranslationsMiddleware(
requestPageSettings(context, action.tabId)
}
}
+ TranslationOperation.FETCH_OFFER_SETTING -> {
+ scope.launch {
+ requestOfferSetting(context, action.tabId)
+ }
+ }
+
TranslationOperation.FETCH_AUTOMATIC_LANGUAGE_SETTINGS -> {
scope.launch {
requestLanguageSettings(context, action.tabId)
@@ -86,7 +92,7 @@ class TranslationsMiddleware(
}
TranslationOperation.FETCH_NEVER_TRANSLATE_SITES -> {
scope.launch {
- getNeverTranslateSites(context, action.tabId)
+ requestNeverTranslateSites(context, action.tabId)
}
}
TranslationOperation.TRANSLATE,
@@ -108,7 +114,7 @@ class TranslationsMiddleware(
is TranslationsAction.RemoveNeverTranslateSiteAction -> {
scope.launch {
- removeNeverTranslateSite(context, action.tabId, action.origin)
+ removeNeverTranslateSite(context, action.origin)
}
}
@@ -151,6 +157,25 @@ class TranslationsMiddleware(
}
}
}
+
+ is TranslationsAction.UpdateGlobalOfferTranslateSettingAction -> {
+ scope.launch {
+ updateAlwaysOfferPopupPageSetting(
+ setting = action.offerTranslation,
+ )
+ }
+ }
+
+ is TranslationsAction.UpdateLanguageSettingsAction -> {
+ scope.launch {
+ updateLanguageSetting(
+ context = context,
+ languageCode = action.languageCode,
+ setting = action.setting,
+ )
+ }
+ }
+
else -> {
// no-op
}
@@ -168,6 +193,8 @@ class TranslationsMiddleware(
* Language Support - [requestSupportedLanguages]
* Language Models - [requestLanguageModels]
* Language Settings - [requestLanguageSettings]
+ * Never Translate Sites List - [requestNeverTranslateSites]
+ * Offer Setting - [requestOfferSetting]
*
* @param context Context to use to dispatch to the store.
*/
@@ -177,6 +204,8 @@ class TranslationsMiddleware(
requestSupportedLanguages(context)
requestLanguageModels(context)
requestLanguageSettings(context)
+ requestNeverTranslateSites(context)
+ requestOfferSetting(context)
}
/**
@@ -336,22 +365,22 @@ class TranslationsMiddleware(
}
/**
- * Retrieves the list of never translate sites using [scope] and dispatches the result to the
- * store via [TranslationsAction.SetNeverTranslateSitesAction] or else dispatches the failure
- * [TranslationsAction.TranslateExceptionAction].
+ * Retrieves the list of never translate sites and dispatches the result to the
+ * store via [TranslationsAction.SetNeverTranslateSitesAction] or else
+ * dispatches the failure via [TranslationsAction.EngineExceptionAction] and
+ * when a [tabId] is provided, [TranslationsAction.TranslateExceptionAction].
*
* @param context Context to use to dispatch to the store.
* @param tabId Tab ID associated with the request.
*/
- private fun getNeverTranslateSites(
+ private fun requestNeverTranslateSites(
context: MiddlewareContext,
- tabId: String,
+ tabId: String? = null,
) {
engine.getNeverTranslateSiteList(
onSuccess = {
context.store.dispatch(
TranslationsAction.SetNeverTranslateSitesAction(
- tabId = tabId,
neverTranslateSites = it,
),
)
@@ -360,12 +389,19 @@ class TranslationsMiddleware(
onError = {
context.store.dispatch(
- TranslationsAction.TranslateExceptionAction(
- tabId = tabId,
- operation = TranslationOperation.FETCH_NEVER_TRANSLATE_SITES,
- translationError = TranslationError.CouldNotLoadNeverTranslateSites(it),
+ TranslationsAction.EngineExceptionAction(
+ error = TranslationError.CouldNotLoadNeverTranslateSites(it),
),
)
+ if (tabId != null) {
+ context.store.dispatch(
+ TranslationsAction.TranslateExceptionAction(
+ tabId = tabId,
+ operation = TranslationOperation.FETCH_NEVER_TRANSLATE_SITES,
+ translationError = TranslationError.CouldNotLoadNeverTranslateSites(it),
+ ),
+ )
+ }
logger.error("Error requesting never translate sites: ", it)
},
)
@@ -377,38 +413,23 @@ class TranslationsMiddleware(
* [TranslationsAction.TranslateExceptionAction].
*
* @param context Context to use to dispatch to the store.
- * @param tabId Tab ID associated with the request.
* @param origin A site origin URI that will have the specified never translate permission set.
*/
private fun removeNeverTranslateSite(
context: MiddlewareContext,
- tabId: String,
origin: String,
) {
engine.setNeverTranslateSpecifiedSite(
origin = origin,
setting = false,
onSuccess = {
- logger.info("Success requesting never translate sites.")
-
- // Fetch page settings to ensure the state matches the engine.
- context.store.dispatch(
- TranslationsAction.OperationRequestedAction(
- tabId = tabId,
- operation = TranslationOperation.FETCH_PAGE_SETTINGS,
- ),
- )
+ logger.info("Success changing never translate sites.")
},
onError = {
logger.error("Error removing site from never translate list: ", it)
-
- // Fetch never translate sites to ensure the state matches the engine.
- context.store.dispatch(
- TranslationsAction.OperationRequestedAction(
- tabId = tabId,
- operation = TranslationOperation.FETCH_NEVER_TRANSLATE_SITES,
- ),
- )
+ // Fetch never translate sites to ensure the state matches the engine, because it
+ // was proactively removed in the reducer.
+ requestNeverTranslateSites(context)
},
)
}
@@ -471,6 +492,38 @@ class TranslationsMiddleware(
}
}
+ /**
+ * Retrieves the setting to always offer to translate and dispatches the result to the
+ * store via [TranslationsAction.SetGlobalOfferTranslateSettingAction]. Will additionally
+ * dispatch a request to update page settings, when a [tabId] is provided.
+ *
+ * @param context Context to use to dispatch to the store.
+ * @param tabId Tab ID associated with the request.
+ */
+ private fun requestOfferSetting(
+ context: MiddlewareContext,
+ tabId: String? = null,
+ ) {
+ logger.info("Requesting offer setting.")
+ val alwaysOfferPopup: Boolean = engine.getTranslationsOfferPopup()
+
+ context.store.dispatch(
+ TranslationsAction.SetGlobalOfferTranslateSettingAction(
+ offerTranslation = alwaysOfferPopup,
+ ),
+ )
+
+ if (tabId != null) {
+ // Fetch page settings to ensure the state matches the engine.
+ context.store.dispatch(
+ TranslationsAction.OperationRequestedAction(
+ tabId = tabId,
+ operation = TranslationOperation.FETCH_PAGE_SETTINGS,
+ ),
+ )
+ }
+ }
+
/**
* Fetches the always or never language setting synchronously from the engine. Will
* return null if an error occurs.
@@ -708,8 +761,8 @@ class TranslationsMiddleware(
/**
* Updates the language settings with the [Engine].
*
- * If an error occurs, then the method will request the page settings be re-fetched and set on
- * the browser store.
+ * If an error occurs, and a [tabId] is known then the method will request the page settings be
+ * re-fetched and set on the browser store.
*
* @param context The context used to request the page settings.
* @param tabId Tab ID associated with the request.
@@ -718,7 +771,7 @@ class TranslationsMiddleware(
*/
private fun updateLanguageSetting(
context: MiddlewareContext,
- tabId: String,
+ tabId: String? = null,
languageCode: String,
setting: LanguageSetting,
) {
@@ -729,26 +782,37 @@ class TranslationsMiddleware(
languageSetting = setting,
onSuccess = {
- // Ensure the session's page settings remain in sync with this update.
- context.store.dispatch(
- TranslationsAction.OperationRequestedAction(
- tabId = tabId,
- operation = TranslationOperation.FETCH_AUTOMATIC_LANGUAGE_SETTINGS,
- ),
- )
+ // Value was proactively updated in [TranslationsStateReducer] for
+ // [TranslationsBrowserState.languageSettings]
+
+ if (tabId != null) {
+ // Ensure the session's page settings remain in sync with this update.
+ context.store.dispatch(
+ TranslationsAction.OperationRequestedAction(
+ tabId = tabId,
+ operation = TranslationOperation.FETCH_AUTOMATIC_LANGUAGE_SETTINGS,
+ ),
+ )
+ }
+
logger.info("Successfully updated the language preference.")
},
onError = {
logger.error("Could not update the language preference.", it)
+ // The browser store [TranslationsBrowserState.languageSettings] is out of sync,
+ // re-request to sync the state.
+ requestLanguageSettings(context, tabId)
- // Fetch page settings to ensure the state matches the engine.
- context.store.dispatch(
- TranslationsAction.OperationRequestedAction(
- tabId = tabId,
- operation = TranslationOperation.FETCH_PAGE_SETTINGS,
- ),
- )
+ if (tabId != null) {
+ // Fetch page settings to ensure the state matches the engine.
+ context.store.dispatch(
+ TranslationsAction.OperationRequestedAction(
+ tabId = tabId,
+ operation = TranslationOperation.FETCH_PAGE_SETTINGS,
+ ),
+ )
+ }
},
)
}
diff --git a/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/ext/TabSessionState.kt b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/ext/TabSessionState.kt
new file mode 100644
index 0000000000..b0f1c2e9e9
--- /dev/null
+++ b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/ext/TabSessionState.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.browser.state.ext
+
+import mozilla.components.browser.state.state.TabSessionState
+
+/**
+ * Returns the URL of the [TabSessionState].
+ */
+fun TabSessionState.getUrl(): String? {
+ return if (this.readerState.active) {
+ this.readerState.activeUrl
+ } else {
+ this.content.url
+ }
+}
diff --git a/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/reducer/TranslationsStateReducer.kt b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/reducer/TranslationsStateReducer.kt
index 266964455e..89c7ee3ed4 100644
--- a/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/reducer/TranslationsStateReducer.kt
+++ b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/reducer/TranslationsStateReducer.kt
@@ -62,11 +62,8 @@ internal object TranslationsStateReducer {
}
// Checking for if the translations engine is in the fully translated state or not based
- // on the values of the translation pair.
- if (action.translationEngineState.requestedTranslationPair == null ||
- action.translationEngineState.requestedTranslationPair?.fromLanguage == null ||
- action.translationEngineState.requestedTranslationPair?.toLanguage == null
- ) {
+ // on if a visual change has occurred on the browser.
+ if (action.translationEngineState.hasVisibleChange != true) {
// In an untranslated state
var translationsError: TranslationError? = null
if (action.translationEngineState.detectedLanguages?.supportedDocumentLang == false) {
@@ -111,9 +108,9 @@ internal object TranslationsStateReducer {
is TranslationsAction.TranslateSuccessAction -> {
when (action.operation) {
TranslationOperation.TRANSLATE -> {
+ // The isTranslated state will be identified on a translation state change.
state.copyWithTranslationsState(action.tabId) {
it.copy(
- isTranslated = true,
isTranslateProcessing = false,
translationError = null,
)
@@ -163,6 +160,17 @@ internal object TranslationsStateReducer {
}
}
+ TranslationOperation.FETCH_OFFER_SETTING -> {
+ // Reset the error state, and then generally expect
+ // [TranslationsAction.SetGlobalOfferTranslateSettingAction] to update state in the
+ // success case.
+ state.copyWithTranslationsState(action.tabId) {
+ it.copy(
+ settingsError = null,
+ )
+ }
+ }
+
TranslationOperation.FETCH_AUTOMATIC_LANGUAGE_SETTINGS -> {
state.copy(
translationEngine = state.translationEngine.copy(
@@ -175,11 +183,11 @@ internal object TranslationsStateReducer {
// Reset the error state, and then generally expect
// [TranslationsAction.SetNeverTranslateSitesAction] to update
// state in the success case.
- state.copyWithTranslationsState(action.tabId) {
- it.copy(
+ state.copy(
+ translationEngine = state.translationEngine.copy(
neverTranslateSites = null,
- )
- }
+ ),
+ )
}
}
}
@@ -229,6 +237,14 @@ internal object TranslationsStateReducer {
}
}
+ TranslationOperation.FETCH_OFFER_SETTING -> {
+ state.copyWithTranslationsState(action.tabId) {
+ it.copy(
+ translationError = action.translationError,
+ )
+ }
+ }
+
TranslationOperation.FETCH_AUTOMATIC_LANGUAGE_SETTINGS -> {
state.copyWithTranslationsState(action.tabId) {
it.copy(
@@ -240,7 +256,6 @@ internal object TranslationsStateReducer {
TranslationOperation.FETCH_NEVER_TRANSLATE_SITES -> {
state.copyWithTranslationsState(action.tabId) {
it.copy(
- neverTranslateSites = null,
settingsError = action.translationError,
)
}
@@ -277,20 +292,20 @@ internal object TranslationsStateReducer {
}
is TranslationsAction.SetNeverTranslateSitesAction ->
- state.copyWithTranslationsState(action.tabId) {
- it.copy(
+ state.copy(
+ translationEngine = state.translationEngine.copy(
neverTranslateSites = action.neverTranslateSites,
- )
- }
+ ),
+ )
is TranslationsAction.RemoveNeverTranslateSiteAction -> {
- val neverTranslateSites = state.findTab(action.tabId)?.translationsState?.neverTranslateSites
+ val neverTranslateSites = state.translationEngine.neverTranslateSites
val updatedNeverTranslateSites = neverTranslateSites?.filter { it != action.origin }?.toList()
- state.copyWithTranslationsState(action.tabId) {
- it.copy(
+ state.copy(
+ translationEngine = state.translationEngine.copy(
neverTranslateSites = updatedNeverTranslateSites,
- )
- }
+ ),
+ )
}
is TranslationsAction.OperationRequestedAction ->
@@ -326,12 +341,20 @@ internal object TranslationsStateReducer {
}
}
+ TranslationOperation.FETCH_OFFER_SETTING -> {
+ state.copy(
+ translationEngine = state.translationEngine.copy(
+ offerTranslation = null,
+ ),
+ )
+ }
+
TranslationOperation.FETCH_NEVER_TRANSLATE_SITES -> {
- state.copyWithTranslationsState(action.tabId) {
- it.copy(
+ state.copy(
+ translationEngine = state.translationEngine.copy(
neverTranslateSites = null,
- )
- }
+ ),
+ )
}
TranslationOperation.TRANSLATE, TranslationOperation.RESTORE -> {
// No state change for these operations
@@ -400,6 +423,35 @@ internal object TranslationsStateReducer {
}
}
+ is TranslationsAction.UpdateLanguageSettingsAction -> {
+ val languageSettings = state.translationEngine.languageSettings?.toMutableMap()
+ // Only set when keys are present.
+ if (languageSettings?.get(action.languageCode) != null) {
+ languageSettings[action.languageCode] = action.setting
+ }
+ state.copy(
+ translationEngine = state.translationEngine.copy(
+ languageSettings = languageSettings,
+ ),
+ )
+ }
+
+ is TranslationsAction.SetGlobalOfferTranslateSettingAction -> {
+ state.copy(
+ translationEngine = state.translationEngine.copy(
+ offerTranslation = action.offerTranslation,
+ ),
+ )
+ }
+
+ is TranslationsAction.UpdateGlobalOfferTranslateSettingAction -> {
+ state.copy(
+ translationEngine = state.translationEngine.copy(
+ offerTranslation = action.offerTranslation,
+ ),
+ )
+ }
+
is TranslationsAction.SetEngineSupportedAction -> {
state.copy(
translationEngine = state.translationEngine.copy(
diff --git a/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/state/TranslationsBrowserState.kt b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/state/TranslationsBrowserState.kt
index 2fb937f9f3..c34e1bc062 100644
--- a/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/state/TranslationsBrowserState.kt
+++ b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/state/TranslationsBrowserState.kt
@@ -13,17 +13,21 @@ import mozilla.components.concept.engine.translate.TranslationSupport
* Value type that represents the state of the translations engine within a [BrowserState].
*
* @property isEngineSupported Whether the translations engine supports the device architecture.
+ * @property offerTranslation Whether to offer translations or not to the user.
* @property supportedLanguages Set of languages the translation engine supports.
* @property languageModels Set of language machine learning translation models the translation engine has available.
* @property languageSettings A map containing a key of BCP 47 language code and its
* [LanguageSetting] to represent the automatic language settings.
+ * @property neverTranslateSites List of sites the user has opted to never translate.
* @property engineError Holds the error state of the translations engine.
* See [TranslationsState.translationError] for session level errors.
*/
data class TranslationsBrowserState(
val isEngineSupported: Boolean? = null,
+ val offerTranslation: Boolean? = null,
val supportedLanguages: TranslationSupport? = null,
val languageModels: List? = null,
val languageSettings: Map? = null,
+ val neverTranslateSites: List? = null,
val engineError: TranslationError? = null,
)
diff --git a/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/state/TranslationsState.kt b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/state/TranslationsState.kt
index 8c05340928..d70d6e1492 100644
--- a/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/state/TranslationsState.kt
+++ b/mobile/android/android-components/components/browser/state/src/main/java/mozilla/components/browser/state/state/TranslationsState.kt
@@ -23,7 +23,6 @@ import mozilla.components.concept.engine.translate.TranslationPageSettings
* translation engine requires the pair's ML models to be present on the device to complete a
* translation.
* @property pageSettings The translation engine settings that relate to the current page.
- * @property neverTranslateSites List of sites the user has opted to never translate.
* @property translationError Type of error that occurred when acquiring resources, translating, or
* restoring a translation.
* @property settingsError Type of error that occurred when acquiring resources or setting preferences.
@@ -37,7 +36,6 @@ data class TranslationsState(
val isRestoreProcessing: Boolean = false,
val translationDownloadSize: TranslationDownloadSize? = null,
val pageSettings: TranslationPageSettings? = null,
- val neverTranslateSites: List? = null,
val translationError: TranslationError? = null,
val settingsError: TranslationError? = null,
)
diff --git a/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/action/TranslationsActionTest.kt b/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/action/TranslationsActionTest.kt
index 2a12cda264..ecbbae9c61 100644
--- a/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/action/TranslationsActionTest.kt
+++ b/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/action/TranslationsActionTest.kt
@@ -95,6 +95,7 @@ class TranslationsActionTest {
error = null,
isEngineReady = true,
requestedTranslationPair = TranslationPair(fromLanguage = "es", toLanguage = "en"),
+ hasVisibleChange = true,
)
store.dispatch(TranslationsAction.TranslateStateChangeAction(tabId = tab.id, translationEngineState = translatedEngineState))
@@ -110,6 +111,7 @@ class TranslationsActionTest {
error = null,
isEngineReady = true,
requestedTranslationPair = TranslationPair(fromLanguage = null, toLanguage = null),
+ hasVisibleChange = false,
)
store.dispatch(TranslationsAction.TranslateStateChangeAction(tabId = tab.id, nonTranslatedEngineState))
@@ -264,7 +266,6 @@ class TranslationsActionTest {
store.dispatch(TranslationsAction.TranslateSuccessAction(tabId = tab.id, operation = TranslationOperation.TRANSLATE))
.joinBlocking()
assertEquals(false, tabState().translationsState.isTranslateProcessing)
- assertEquals(true, tabState().translationsState.isTranslated)
assertEquals(null, tabState().translationsState.translationError)
}
@@ -347,44 +348,41 @@ class TranslationsActionTest {
@Test
fun `WHEN a SetNeverTranslateSitesAction is dispatched AND successful THEN update neverTranslateSites`() {
// Initial
- assertEquals(null, tabState().translationsState.neverTranslateSites)
+ assertNull(store.state.translationEngine.neverTranslateSites)
// Action started
val neverTranslateSites = listOf("google.com")
store.dispatch(
TranslationsAction.SetNeverTranslateSitesAction(
- tabId = tab.id,
neverTranslateSites = neverTranslateSites,
),
).joinBlocking()
// Action success
- assertEquals(neverTranslateSites, tabState().translationsState.neverTranslateSites)
+ assertEquals(neverTranslateSites, store.state.translationEngine.neverTranslateSites)
}
@Test
fun `WHEN a RemoveNeverTranslateSiteAction is dispatched AND successful THEN update neverTranslateSites`() {
// Initial add to neverTranslateSites
- assertEquals(null, tabState().translationsState.neverTranslateSites)
+ assertNull(store.state.translationEngine.neverTranslateSites)
val neverTranslateSites = listOf("google.com")
store.dispatch(
TranslationsAction.SetNeverTranslateSitesAction(
- tabId = tab.id,
neverTranslateSites = neverTranslateSites,
),
).joinBlocking()
- assertEquals(neverTranslateSites, tabState().translationsState.neverTranslateSites)
+ assertEquals(neverTranslateSites, store.state.translationEngine.neverTranslateSites)
// Action started
store.dispatch(
TranslationsAction.RemoveNeverTranslateSiteAction(
- tabId = tab.id,
origin = "google.com",
),
).joinBlocking()
// Action success
- assertEquals(listOf(), tabState().translationsState.neverTranslateSites)
+ assertEquals(listOf(), store.state.translationEngine.neverTranslateSites)
}
@Test
@@ -451,7 +449,6 @@ class TranslationsActionTest {
),
).joinBlocking()
assertEquals(null, tabState().translationsState.translationError)
- assertEquals(true, tabState().translationsState.isTranslated)
assertEquals(false, tabState().translationsState.isTranslateProcessing)
// RESTORE usage
@@ -900,4 +897,88 @@ class TranslationsActionTest {
// Final state
assertEquals(languageModels, store.state.translationEngine.languageModels)
}
+
+ @Test
+ fun `WHEN SetOfferTranslateSettingAction is called then set offerToTranslate`() {
+ // Initial State
+ assertNull(store.state.translationEngine.offerTranslation)
+
+ // Action started
+ store.dispatch(
+ TranslationsAction.SetGlobalOfferTranslateSettingAction(
+ offerTranslation = false,
+ ),
+ ).joinBlocking()
+
+ // Action success
+ assertFalse(store.state.translationEngine.offerTranslation!!)
+ }
+
+ @Test
+ fun `WHEN UpdateOfferTranslateSettingAction is called then set offerToTranslate`() {
+ // Initial State
+ assertNull(store.state.translationEngine.offerTranslation)
+
+ // Action started
+ store.dispatch(
+ TranslationsAction.UpdateGlobalOfferTranslateSettingAction(
+ offerTranslation = false,
+ ),
+ ).joinBlocking()
+
+ // Action success
+ assertFalse(store.state.translationEngine.offerTranslation!!)
+ }
+
+ @Test
+ fun `WHEN UpdateGlobalLanguageSettingAction is called then update languageSettings`() {
+ // Initial State
+ assertNull(store.state.translationEngine.languageSettings)
+
+ // No-op null test
+ store.dispatch(
+ TranslationsAction.UpdateLanguageSettingsAction(
+ languageCode = "fr",
+ setting = LanguageSetting.ALWAYS,
+ ),
+ ).joinBlocking()
+
+ assertNull(store.state.translationEngine.languageSettings)
+
+ // Setting Initial State
+ val languageSettings = mapOf(
+ "en" to LanguageSetting.OFFER,
+ "es" to LanguageSetting.NEVER,
+ "de" to LanguageSetting.ALWAYS,
+ )
+
+ store.dispatch(
+ TranslationsAction.SetLanguageSettingsAction(
+ languageSettings = languageSettings,
+ ),
+ ).joinBlocking()
+
+ assertEquals(languageSettings, store.state.translationEngine.languageSettings)
+
+ // No-op update test
+ store.dispatch(
+ TranslationsAction.UpdateLanguageSettingsAction(
+ languageCode = "fr",
+ setting = LanguageSetting.ALWAYS,
+ ),
+ ).joinBlocking()
+
+ assertEquals(languageSettings, store.state.translationEngine.languageSettings)
+
+ // Main action started
+ store.dispatch(
+ TranslationsAction.UpdateLanguageSettingsAction(
+ languageCode = "es",
+ setting = LanguageSetting.ALWAYS,
+ ),
+ ).joinBlocking()
+
+ // Action success
+ assertEquals(LanguageSetting.ALWAYS, store.state.translationEngine.languageSettings!!["es"])
+ }
}
diff --git a/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/engine/middleware/EngineDelegateMiddlewareTest.kt b/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/engine/middleware/EngineDelegateMiddlewareTest.kt
index 5eaeb328ee..13365b57a7 100644
--- a/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/engine/middleware/EngineDelegateMiddlewareTest.kt
+++ b/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/engine/middleware/EngineDelegateMiddlewareTest.kt
@@ -263,6 +263,7 @@ class EngineDelegateMiddlewareTest {
EngineAction.LoadUrlAction(
"test-tab",
"https://www.firefox.com",
+ includeParent = true,
),
).joinBlocking()
diff --git a/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/engine/middleware/LinkingMiddlewareTest.kt b/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/engine/middleware/LinkingMiddlewareTest.kt
index 89939b71a2..c24f838c02 100644
--- a/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/engine/middleware/LinkingMiddlewareTest.kt
+++ b/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/engine/middleware/LinkingMiddlewareTest.kt
@@ -96,7 +96,9 @@ class LinkingMiddlewareTest {
store.dispatch(EngineAction.LinkEngineSessionAction(parent.id, parentEngineSession)).joinBlocking()
val childEngineSession: EngineSession = mock()
- store.dispatch(EngineAction.LinkEngineSessionAction(child.id, childEngineSession)).joinBlocking()
+ store.dispatch(
+ EngineAction.LinkEngineSessionAction(child.id, childEngineSession, includeParent = true),
+ ).joinBlocking()
dispatcher.scheduler.advanceUntilIdle()
diff --git a/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/engine/middleware/TranslationsMiddlewareTest.kt b/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/engine/middleware/TranslationsMiddlewareTest.kt
index cf30f7060e..dadc88de52 100644
--- a/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/engine/middleware/TranslationsMiddlewareTest.kt
+++ b/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/engine/middleware/TranslationsMiddlewareTest.kt
@@ -326,6 +326,35 @@ class TranslationsMiddlewareTest {
)
}
+ @Test
+ fun `WHEN InitTranslationsBrowserState is dispatched AND the engine is supported THEN SetNeverTranslateSitesAction is also dispatched`() = runTest {
+ // Send Action
+ translationsMiddleware.invoke(context = context, next = {}, action = TranslationsAction.InitTranslationsBrowserState)
+
+ // Set the engine to support
+ val engineSupportedCallback = argumentCaptor<((Boolean) -> Unit)>()
+ // At least once, since InitAction also will trigger this
+ verify(engine, atLeastOnce()).isTranslationsEngineSupported(
+ onSuccess = engineSupportedCallback.capture(),
+ onError = any(),
+ )
+ engineSupportedCallback.value.invoke(true)
+
+ val neverTranslateSitesCallBack = argumentCaptor<((List) -> Unit)>()
+ verify(engine, atLeastOnce()).getNeverTranslateSiteList(onSuccess = neverTranslateSitesCallBack.capture(), onError = any())
+ val mockNeverTranslate = listOf("www.mozilla.org")
+ neverTranslateSitesCallBack.value.invoke(mockNeverTranslate)
+
+ waitForIdle()
+
+ // Verifying at least once
+ verify(store, atLeastOnce()).dispatch(
+ TranslationsAction.SetNeverTranslateSitesAction(
+ neverTranslateSites = mockNeverTranslate,
+ ),
+ )
+ }
+
@Test
fun `WHEN InitTranslationsBrowserState is dispatched AND has an issue with the engine THEN EngineExceptionAction is dispatched`() = runTest() {
// Send Action
@@ -726,7 +755,6 @@ class TranslationsMiddlewareTest {
verify(context.store).dispatch(
TranslationsAction.SetNeverTranslateSitesAction(
- tabId = tab.id,
neverTranslateSites = neverTranslateSites,
),
)
@@ -825,7 +853,7 @@ class TranslationsMiddlewareTest {
}
@Test
- fun `WHEN RemoveNeverTranslateSiteAction is dispatched AND removing is unsuccessful THEN FETCH_NEVER_TRANSLATE_SITES is dispatched`() = runTest {
+ fun `WHEN RemoveNeverTranslateSiteAction is dispatched AND removing is unsuccessful THEN SetNeverTranslateSitesAction is dispatched`() = runTest {
val errorCallback = argumentCaptor<((Throwable) -> Unit)>()
whenever(
engine.setNeverTranslateSpecifiedSite(
@@ -838,45 +866,20 @@ class TranslationsMiddlewareTest {
val action =
TranslationsAction.RemoveNeverTranslateSiteAction(
- tabId = tab.id,
origin = "google.com",
)
translationsMiddleware.invoke(context, {}, action)
waitForIdle()
- // Verify Dispatch
- verify(store).dispatch(
- TranslationsAction.OperationRequestedAction(
- tabId = tab.id,
- operation = TranslationOperation.FETCH_NEVER_TRANSLATE_SITES,
- ),
- )
- waitForIdle()
- }
-
- @Test
- fun `WHEN RemoveNeverTranslateSiteAction is dispatched AND removing is successful THEN FETCH_PAGE_SETTINGS is dispatched`() = runTest {
- val sitesCallback = argumentCaptor<(() -> Unit)>()
- val action =
- TranslationsAction.RemoveNeverTranslateSiteAction(
- tabId = tab.id,
- origin = "google.com",
- )
- translationsMiddleware.invoke(context, {}, action)
- verify(engine).setNeverTranslateSpecifiedSite(
- origin = any(),
- setting = anyBoolean(),
- onSuccess = sitesCallback.capture(),
- onError = any(),
- )
- sitesCallback.value.invoke()
- waitForIdle()
+ val neverTranslateSitesCallBack = argumentCaptor<((List) -> Unit)>()
+ verify(engine, atLeastOnce()).getNeverTranslateSiteList(onSuccess = neverTranslateSitesCallBack.capture(), onError = any())
+ val mockNeverTranslate = listOf("www.mozilla.org")
+ neverTranslateSitesCallBack.value.invoke(mockNeverTranslate)
// Verify Dispatch
verify(store).dispatch(
- TranslationsAction.OperationRequestedAction(
- tabId = tab.id,
- operation = TranslationOperation.FETCH_PAGE_SETTINGS,
+ TranslationsAction.SetNeverTranslateSitesAction(
+ neverTranslateSites = mockNeverTranslate,
),
)
waitForIdle()
@@ -942,4 +945,116 @@ class TranslationsMiddlewareTest {
waitForIdle()
}
+
+ @Test
+ fun `WHEN InitTranslationsBrowserState is dispatched AND the engine is supported THEN SetOfferTranslateSettingAction is also dispatched`() = runTest {
+ // Send Action
+ translationsMiddleware.invoke(context = context, next = {}, action = TranslationsAction.InitTranslationsBrowserState)
+
+ // Set the engine to support
+ val engineSupportedCallback = argumentCaptor<((Boolean) -> Unit)>()
+ // At least once, since InitAction also will trigger this
+ verify(engine, atLeastOnce()).isTranslationsEngineSupported(
+ onSuccess = engineSupportedCallback.capture(),
+ onError = any(),
+ )
+ engineSupportedCallback.value.invoke(true)
+
+ // Verify results for offer
+ verify(engine, atLeastOnce()).getTranslationsOfferPopup()
+ waitForIdle()
+
+ // Verifying at least once
+ verify(store, atLeastOnce()).dispatch(
+ TranslationsAction.SetGlobalOfferTranslateSettingAction(
+ offerTranslation = false,
+ ),
+ )
+
+ waitForIdle()
+ }
+
+ @Test
+ fun `WHEN FETCH_OFFER_SETTING is dispatched with a tab id THEN SetOfferTranslateSettingAction and SetPageSettingsAction are also dispatched`() = runTest {
+ // Set the mock offer value
+ whenever(
+ engine.getTranslationsOfferPopup(),
+ ).thenAnswer { true }
+
+ // Send Action
+ val action =
+ TranslationsAction.OperationRequestedAction(
+ tabId = tab.id,
+ operation = TranslationOperation.FETCH_OFFER_SETTING,
+ )
+ translationsMiddleware.invoke(context, {}, action)
+ waitForIdle()
+
+ // Verify Dispatch
+ verify(store, atLeastOnce()).dispatch(
+ TranslationsAction.SetGlobalOfferTranslateSettingAction(
+ offerTranslation = true,
+ ),
+ )
+
+ // Since we had a tabId, this call will also happen
+ verify(store, atLeastOnce()).dispatch(
+ TranslationsAction.SetPageSettingsAction(
+ tabId = tab.id,
+ pageSettings = any(),
+ ),
+ )
+
+ waitForIdle()
+ }
+
+ @Test
+ fun `WHEN UpdateOfferTranslateSettingAction is called then setTranslationsOfferPopup is called on the engine`() = runTest {
+ // Send Action
+ val action =
+ TranslationsAction.UpdateGlobalOfferTranslateSettingAction(
+ offerTranslation = true,
+ )
+ translationsMiddleware.invoke(context, {}, action)
+ waitForIdle()
+
+ // Verify offer was set
+ verify(engine, atLeastOnce()).setTranslationsOfferPopup(offer = true)
+ waitForIdle()
+ }
+
+ @Test
+ fun `WHEN UpdateLanguageSettingsAction is dispatched and fails THEN SetLanguageSettingsAction is dispatched`() = runTest {
+ // Send Action
+ val action =
+ TranslationsAction.UpdateLanguageSettingsAction(
+ languageCode = "es",
+ setting = LanguageSetting.ALWAYS,
+ )
+ translationsMiddleware.invoke(context, {}, action)
+
+ waitForIdle()
+
+ // Mock engine error
+ val updateLanguagesErrorCallback = argumentCaptor<((Throwable) -> Unit)>()
+ verify(engine).setLanguageSetting(
+ languageCode = any(),
+ languageSetting = any(),
+ onSuccess = any(),
+ onError = updateLanguagesErrorCallback.capture(),
+ )
+ updateLanguagesErrorCallback.value.invoke(Throwable())
+
+ waitForIdle()
+
+ // Verify Dispatch
+ val languageSettingsCallback = argumentCaptor<((Map) -> Unit)>()
+ verify(engine, atLeastOnce()).getLanguageSettings(
+ onSuccess = languageSettingsCallback.capture(),
+ onError = any(),
+ )
+ val mockLanguageSetting = mapOf("en" to LanguageSetting.OFFER)
+ languageSettingsCallback.value.invoke(mockLanguageSetting)
+ waitForIdle()
+ }
}
diff --git a/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/ext/TabSessionStateTest.kt b/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/ext/TabSessionStateTest.kt
new file mode 100644
index 0000000000..1202afb1c6
--- /dev/null
+++ b/mobile/android/android-components/components/browser/state/src/test/java/mozilla/components/browser/state/ext/TabSessionStateTest.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.browser.state.ext
+
+import mozilla.components.browser.state.state.ReaderState
+import mozilla.components.browser.state.state.createTab
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class TabSessionStateTest {
+
+ @Test
+ fun `GIVEN reader mode is active WHEN get url extension property is fetched THEN return the active url`() {
+ val readerUrl = "moz-extension://1234"
+ val activeUrl = "https://mozilla.org"
+ val readerTab = createTab(
+ url = readerUrl,
+ readerState = ReaderState(active = true, activeUrl = activeUrl),
+ title = "Mozilla",
+ )
+
+ assertEquals(activeUrl, readerTab.getUrl())
+ }
+
+ @Test
+ fun `WHEN get url extension property is fetched THEN return the content url`() {
+ val url = "https://mozilla.org"
+ val tab = createTab(url = url)
+
+ assertEquals(url, tab.getUrl())
+ }
+}
diff --git a/mobile/android/android-components/components/browser/toolbar/src/main/res/values-nb-rNO/strings.xml b/mobile/android/android-components/components/browser/toolbar/src/main/res/values-nb-rNO/strings.xml
index f9cb99c55d..9d9bcbfc13 100644
--- a/mobile/android/android-components/components/browser/toolbar/src/main/res/values-nb-rNO/strings.xml
+++ b/mobile/android/android-components/components/browser/toolbar/src/main/res/values-nb-rNO/strings.xml
@@ -2,6 +2,7 @@
Meny
+
TømSporingsbeskyttelse er på
@@ -14,5 +15,5 @@
Laster
- Noe av innholdet er blokkert av autoavspillings-innstillingene
+ Noe av innholdet er blokkert av autoavspillings-innstillingene
diff --git a/mobile/android/android-components/components/browser/toolbar/src/main/res/values-sc/strings.xml b/mobile/android/android-components/components/browser/toolbar/src/main/res/values-sc/strings.xml
index 5194a4765e..f0b375f172 100644
--- a/mobile/android/android-components/components/browser/toolbar/src/main/res/values-sc/strings.xml
+++ b/mobile/android/android-components/components/browser/toolbar/src/main/res/values-sc/strings.xml
@@ -2,6 +2,7 @@
Menù
+
IsbòidaS’amparu contra sa sighidura est ativu
@@ -13,4 +14,6 @@
Informatziones de su situCarrighende
-
+
+ Sa funtzionalidade de riprodutzione automàtica at blocadu cuntenutos
+
diff --git a/mobile/android/android-components/components/browser/toolbar/src/main/res/values/strings.xml b/mobile/android/android-components/components/browser/toolbar/src/main/res/values/strings.xml
index 3c2d7dcf1b..c845e189e7 100644
--- a/mobile/android/android-components/components/browser/toolbar/src/main/res/values/strings.xml
+++ b/mobile/android/android-components/components/browser/toolbar/src/main/res/values/strings.xml
@@ -5,6 +5,7 @@
Menu
+
ClearTracking Protection is on
diff --git a/mobile/android/android-components/components/browser/toolbar2/README.md b/mobile/android/android-components/components/browser/toolbar2/README.md
new file mode 100644
index 0000000000..a0cfe1d6ce
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/README.md
@@ -0,0 +1,37 @@
+# [Android Components](../../../README.md) > Browser > Toolbar2
+
+A customizable toolbar for browsers.
+
+## 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:browser-toolbar2:{latest-version}"
+```
+
+## Facts
+
+This component emits the following [Facts](../../support/base/README.md#Facts):
+
+| Action | Item | Extras | Description |
+|--------|---------|----------------|------------------------------------|
+| CLICK | menu | `menuExtras` | The user opened the overflow menu. |
+| COMMIT | toolbar | `commitExtras` | The user has edited the URL. |
+
+`menuExtras` are additional extras set on the `BrowserMenuBuilder` passed to the `BrowserToolbar` (see [browser-menu](../menu/README.md)).
+
+#### `commitExtras`
+
+| Key | Type | Value |
+|--------------|---------|-----------------------------------|
+| autocomplete | Boolean | Whether the URL was autocompleted |
+| source | String? | Which autocomplete list was used |
+
+## 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/browser/toolbar2/build.gradle b/mobile/android/android-components/components/browser/toolbar2/build.gradle
new file mode 100644
index 0000000000..60d3930430
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/build.gradle
@@ -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/. */
+
+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.browser.toolbar2'
+}
+
+dependencies {
+ api project(':concept-toolbar')
+ api project(':ui-autocomplete')
+ api project(':support-base')
+
+ implementation project(':concept-engine')
+ implementation project(':concept-menu')
+ implementation project(':browser-menu')
+ implementation project(':browser-menu2')
+ implementation project(':ui-icons')
+ implementation project(':ui-colors')
+ implementation project(':ui-widgets')
+ implementation project(':support-ktx')
+
+ implementation ComponentsDependencies.androidx_appcompat
+ implementation ComponentsDependencies.androidx_constraintlayout
+ implementation ComponentsDependencies.androidx_core_ktx
+ implementation ComponentsDependencies.google_material
+
+ implementation ComponentsDependencies.kotlin_coroutines
+
+ testImplementation project(':support-test')
+
+ testImplementation ComponentsDependencies.androidx_test_core
+ testImplementation ComponentsDependencies.androidx_test_junit
+ testImplementation ComponentsDependencies.testing_robolectric
+ testImplementation ComponentsDependencies.testing_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/browser/toolbar2/proguard-rules.pro b/mobile/android/android-components/components/browser/toolbar2/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/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/browser/toolbar2/src/main/AndroidManifest.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/BrowserToolbar.kt b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/BrowserToolbar.kt
new file mode 100644
index 0000000000..e740455385
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/BrowserToolbar.kt
@@ -0,0 +1,691 @@
+/* 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.browser.toolbar2
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View
+import android.view.View.NO_ID
+import android.view.ViewGroup
+import android.widget.ImageButton
+import android.widget.ImageView
+import androidx.annotation.ColorRes
+import androidx.annotation.DrawableRes
+import androidx.annotation.VisibleForTesting
+import androidx.annotation.VisibleForTesting.Companion.PRIVATE
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.content.ContextCompat
+import androidx.core.view.forEach
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import mozilla.components.browser.toolbar2.display.DisplayToolbar
+import mozilla.components.browser.toolbar2.edit.EditToolbar
+import mozilla.components.concept.toolbar.AutocompleteDelegate
+import mozilla.components.concept.toolbar.AutocompleteResult
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.concept.toolbar.Toolbar.Highlight
+import mozilla.components.support.base.android.Padding
+import mozilla.components.support.base.log.logger.Logger
+import mozilla.components.support.ktx.kotlin.trimmed
+import mozilla.components.ui.autocomplete.AutocompleteView
+import mozilla.components.ui.autocomplete.InlineAutocompleteEditText
+import mozilla.components.ui.autocomplete.OnFilterListener
+import mozilla.components.ui.widgets.behavior.EngineViewScrollingBehavior
+import kotlin.coroutines.CoroutineContext
+
+internal fun ImageView.setTintResource(@ColorRes tintColorResource: Int) {
+ if (tintColorResource != NO_ID) {
+ imageTintList = ContextCompat.getColorStateList(context, tintColorResource)
+ }
+}
+
+/**
+ * A customizable toolbar for browsers.
+ *
+ * The toolbar can switch between two modes: display and edit. The display mode displays the current
+ * URL and controls for navigation. In edit mode the current URL can be edited. Those two modes are
+ * implemented by the DisplayToolbar and EditToolbar classes.
+ *
+ * ```
+ * +----------------+
+ * | BrowserToolbar |
+ * +--------+-------+
+ * +
+ * +-------+-------+
+ * | |
+ * +---------v------+ +-------v--------+
+ * | DisplayToolbar | | EditToolbar |
+ * +----------------+ +----------------+
+ * ```
+ */
+@Suppress("TooManyFunctions")
+class BrowserToolbar @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+) : ViewGroup(context, attrs, defStyleAttr), Toolbar {
+ private var state: State = State.DISPLAY
+
+ @VisibleForTesting
+ internal var searchTerms: String = ""
+ private var urlCommitListener: ((String) -> Boolean)? = null
+ var isNavBarEnabled: Boolean = false
+
+ /**
+ * Toolbar in "display mode".
+ */
+ var display = DisplayToolbar(
+ context,
+ this,
+ LayoutInflater.from(context).inflate(
+ R.layout.mozac_browser_toolbar_displaytoolbar,
+ this,
+ false,
+ ),
+ )
+ @VisibleForTesting(otherwise = PRIVATE)
+ internal set
+
+ /**
+ * Toolbar in "edit mode".
+ */
+ var edit = EditToolbar(
+ context,
+ this,
+ LayoutInflater.from(context).inflate(
+ R.layout.mozac_browser_toolbar_edittoolbar,
+ this,
+ false,
+ ),
+ )
+ @VisibleForTesting(otherwise = PRIVATE)
+ internal set
+
+ override var title: String
+ get() = display.title
+ set(value) { display.title = value }
+
+ override var url: CharSequence
+ get() = display.url.toString()
+ set(value) {
+ // We update the display toolbar immediately. We do not do that for the edit toolbar to not
+ // mess with what the user is entering. Instead we will remember the value and update the
+ // edit toolbar whenever we switch to it.
+ display.url = (value as String).trimmed()
+ }
+
+ override var siteSecure: Toolbar.SiteSecurity
+ get() = display.siteSecurity
+ set(value) { display.siteSecurity = value }
+
+ override var highlight: Highlight = Highlight.NONE
+ set(value) {
+ if (field != value) {
+ display.setHighlight(value)
+ field = value
+ }
+ }
+
+ override var siteTrackingProtection: Toolbar.SiteTrackingProtection =
+ Toolbar.SiteTrackingProtection.OFF_GLOBALLY
+ set(value) {
+ if (field != value) {
+ display.setTrackingProtectionState(value)
+ field = value
+ }
+ }
+
+ override var private: Boolean
+ get() = edit.private
+ set(value) { edit.private = value }
+
+ /**
+ * Registers the given listener to be invoked when the user edits the URL.
+ */
+ override fun setOnEditListener(listener: Toolbar.OnEditListener) {
+ edit.editListener = listener
+ }
+
+ /**
+ * Registers the given function to be invoked when users changes text in the toolbar.
+ *
+ * @param filter A function which will perform autocompletion and send results to [AutocompleteDelegate].
+ */
+ override fun setAutocompleteListener(filter: suspend (String, AutocompleteDelegate) -> Unit) {
+ // Our 'filter' knows how to autocomplete, and the 'urlView' knows how to apply results of
+ // autocompletion. Which gives us a lovely delegate chain!
+ // urlView decides when it's appropriate to ask for autocompletion, and in turn we invoke
+ // our 'filter' and send results back to 'urlView'.
+ edit.setAutocompleteListener(filter)
+ }
+
+ override fun refreshAutocomplete() {
+ edit.refreshAutocompleteSuggestion()
+ }
+
+ init {
+ addView(display.rootView)
+ addView(edit.rootView)
+
+ updateState(State.DISPLAY)
+ }
+
+ // We layout the toolbar ourselves to avoid the overhead from using complex ViewGroup implementations
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
+ forEach { child ->
+ child.layout(
+ 0 + paddingLeft,
+ 0 + paddingTop,
+ paddingLeft + child.measuredWidth,
+ paddingTop + child.measuredHeight,
+ )
+ }
+ }
+
+ // We measure the views manually to avoid overhead by using complex ViewGroup implementations
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ // Our toolbar will always use the full width and a fixed height (default) or the provided
+ // height if it's an exact value.
+ val width = MeasureSpec.getSize(widthMeasureSpec)
+ val height = if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY) {
+ MeasureSpec.getSize(heightMeasureSpec)
+ } else {
+ resources.getDimensionPixelSize(R.dimen.mozac_browser_toolbar_default_toolbar_height)
+ }
+
+ setMeasuredDimension(width, height)
+
+ // Let the children measure themselves using our fixed size (with padding subtracted)
+ val childWidth = width - paddingLeft - paddingRight
+ val childHeight = height - paddingTop - paddingBottom
+
+ val childWidthSpec = MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY)
+ val childHeightSpec = MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY)
+
+ forEach { child -> child.measure(childWidthSpec, childHeightSpec) }
+ }
+
+ override fun onBackPressed(): Boolean {
+ if (state == State.EDIT) {
+ displayMode()
+ return true
+ }
+ return false
+ }
+
+ override fun onStop() {
+ display.onStop()
+ }
+
+ override fun setSearchTerms(searchTerms: String) {
+ this.searchTerms = searchTerms.trimmed()
+
+ if (state == State.EDIT) {
+ edit.editSuggestion(this.searchTerms)
+ }
+ }
+
+ override fun displayProgress(progress: Int) {
+ display.updateProgress(progress)
+ }
+
+ override fun setOnUrlCommitListener(listener: (String) -> Boolean) {
+ this.urlCommitListener = listener
+ }
+
+ /**
+ * Declare that the actions (navigation actions, browser actions, page actions) have changed and
+ * should be updated if needed.
+ *
+ * The toolbar will call the visible lambda of every action to determine whether a
+ * view for this action should be added or removed. Additionally bind will be
+ * called on every visible action to update its view.
+ */
+ override fun invalidateActions() {
+ display.invalidateActions()
+ edit.invalidateActions()
+ }
+
+ /**
+ * Adds an action to be displayed on the right side of the toolbar (outside of the URL bounding
+ * box) in display mode.
+ *
+ * If there is not enough room to show all icons then some icons may be moved to an overflow
+ * menu.
+ *
+ * Related:
+ * https://developer.mozilla.org/en-US/Add-ons/WebExtensions/user_interface/Browser_action
+ */
+ override fun addBrowserAction(action: Toolbar.Action) {
+ display.addBrowserAction(action)
+ }
+
+ /**
+ * Removes a previously added browser action (see [addBrowserAction]). If the provided
+ * action was never added, this method has no effect.
+ *
+ * @param action the action to remove.
+ */
+ override fun removeBrowserAction(action: Toolbar.Action) {
+ display.removeBrowserAction(action)
+ }
+
+ /**
+ * Removes a previously added page action (see [addPageAction]). If the provided
+ * action was never added, this method has no effect.
+ *
+ * @param action the action to remove.
+ */
+ override fun removePageAction(action: Toolbar.Action) {
+ display.removePageAction(action)
+ }
+
+ /**
+ * Adds an action to be displayed on the right side of the URL in display mode.
+ *
+ * Related:
+ * https://developer.mozilla.org/en-US/Add-ons/WebExtensions/user_interface/Page_actions
+ */
+ override fun addPageAction(action: Toolbar.Action) {
+ display.addPageAction(action)
+ }
+
+ /**
+ * Adds an action to be display on the far left side of the toolbar. This area is usually used
+ * on larger devices for navigation actions like "back" and "forward".
+ */
+ override fun addNavigationAction(action: Toolbar.Action) {
+ display.addNavigationAction(action)
+ }
+
+ /**
+ * Removes a previously added navigation action (see [addNavigationAction]). If the provided
+ * action was never added, this method has no effect.
+ *
+ * @param action the action to remove.
+ */
+ override fun removeNavigationAction(action: Toolbar.Action) {
+ display.removeNavigationAction(action)
+ }
+
+ /**
+ * Adds an action to be displayed at the start of the URL in edit mode.
+ */
+ override fun addEditActionStart(action: Toolbar.Action) {
+ edit.addEditActionStart(action)
+ }
+
+ /**
+ * Adds an action to be displayed at the end of the URL in edit mode.
+ */
+ override fun addEditActionEnd(action: Toolbar.Action) {
+ edit.addEditActionEnd(action)
+ }
+
+ /**
+ * Removes an action end of the URL in edit mode.
+ */
+ override fun removeEditActionEnd(action: Toolbar.Action) {
+ edit.removeEditActionEnd(action)
+ }
+
+ /**
+ * Hides the menu button in display mode.
+ */
+ override fun hideMenuButton() {
+ display.hideMenuButton()
+ }
+
+ /**
+ * Shows the menu button in display mode.
+ */
+ override fun showMenuButton() {
+ display.showMenuButton()
+ }
+
+ /**
+ * Sets the horizontal padding in display mode.
+ */
+ override fun setDisplayHorizontalPadding(horizontalPadding: Int) {
+ display.setHorizontalPadding(horizontalPadding)
+ }
+
+ /**
+ * Hides the page action separator in display/edit mode.
+ */
+ override fun hidePageActionSeparator() {
+ display.hidePageActionSeparator()
+ edit.hidePageActionSeparator()
+ }
+
+ /**
+ * Shows the page action separator in display/edit mode.
+ */
+ override fun showPageActionSeparator() {
+ display.showPageActionSeparator()
+ edit.showPageActionSeparator()
+ }
+
+ /**
+ * Switches to URL editing mode.
+ *
+ * @param cursorPlacement Where the cursor should be placed after focusing on the URL input field.
+ */
+ override fun editMode(cursorPlacement: Toolbar.CursorPlacement) {
+ val urlValue = if (searchTerms.isEmpty()) url else searchTerms
+ edit.updateUrl(urlValue.toString(), false)
+ updateState(State.EDIT)
+ edit.focus()
+
+ when (cursorPlacement) {
+ Toolbar.CursorPlacement.ALL -> {
+ edit.selectAll()
+ }
+ Toolbar.CursorPlacement.END -> {
+ edit.selectEnd()
+ }
+ }
+ }
+
+ /**
+ * Switches to URL displaying mode.
+ */
+ override fun displayMode() {
+ updateState(State.DISPLAY)
+ }
+
+ /**
+ * Dismisses the display toolbar popup menu.
+ */
+ override fun dismissMenu() {
+ display.views.menu.dismissMenu()
+ }
+
+ override fun enableScrolling() {
+ // Behavior can be changed without us knowing. Not safe to use a memoized value.
+ (layoutParams as? CoordinatorLayout.LayoutParams)?.apply {
+ (behavior as? EngineViewScrollingBehavior)?.enableScrolling()
+ }
+ }
+
+ override fun disableScrolling() {
+ // Behavior can be changed without us knowing. Not safe to use a memoized value.
+ (layoutParams as? CoordinatorLayout.LayoutParams)?.apply {
+ (behavior as? EngineViewScrollingBehavior)?.disableScrolling()
+ }
+ }
+
+ override fun expand() {
+ (layoutParams as? CoordinatorLayout.LayoutParams)?.apply {
+ (behavior as? EngineViewScrollingBehavior)?.forceExpand(this@BrowserToolbar)
+ }
+ }
+
+ override fun collapse() {
+ (layoutParams as? CoordinatorLayout.LayoutParams)?.apply {
+ (behavior as? EngineViewScrollingBehavior)?.forceCollapse(this@BrowserToolbar)
+ }
+ }
+
+ internal fun onUrlEntered(url: String) {
+ if (urlCommitListener?.invoke(url) != false) {
+ // Return to display mode if there's no urlCommitListener or if it returned true. This lets
+ // the app control whether we should switch to display mode automatically.
+ displayMode()
+ }
+ }
+
+ private fun updateState(state: State) {
+ this.state = state
+
+ val (show, hide) = when (state) {
+ State.DISPLAY -> {
+ edit.stopEditing()
+ Pair(display.rootView, edit.rootView)
+ }
+ State.EDIT -> {
+ edit.startEditing()
+ Pair(edit.rootView, display.rootView)
+ }
+ }
+
+ show.visibility = View.VISIBLE
+ hide.visibility = View.GONE
+ }
+
+ private enum class State {
+ DISPLAY,
+ EDIT,
+ }
+
+ /**
+ * An action button to be added to the toolbar.
+ *
+ * @param imageDrawable The drawable to be shown.
+ * @param contentDescription The content description to use.
+ * @param visible Lambda that returns true or false to indicate whether this button should be shown.
+ * @param autoHide Lambda that returns true or false to indicate whether this button should auto hide.
+ * @param weight Lambda that returns an integer to indicate weight of an action. The lesser the weight,
+ * the closer it is to the url. A default weight -1 indicates, the position is not cared for
+ * and action will be appended at the end.
+ * @param background A custom (stateful) background drawable resource to be used.
+ * @param padding a custom [Padding] for this Button.
+ * @param iconTintColorResource Optional ID of color resource to tint the icon.
+ * @param longClickListener Callback that will be invoked whenever the button is long-pressed.
+ * @param listener Callback that will be invoked whenever the button is pressed
+ */
+ open class Button(
+ imageDrawable: Drawable,
+ contentDescription: String,
+ visible: () -> Boolean = { true },
+ autoHide: () -> Boolean = { false },
+ weight: () -> Int = { -1 },
+ @DrawableRes background: Int = 0,
+ val padding: Padding = DEFAULT_PADDING,
+ @ColorRes iconTintColorResource: Int = NO_ID,
+ longClickListener: (() -> Unit)? = null,
+ listener: () -> Unit,
+ ) : Toolbar.ActionButton(
+ imageDrawable,
+ contentDescription,
+ visible,
+ autoHide,
+ weight,
+ background,
+ padding,
+ iconTintColorResource,
+ longClickListener,
+ listener,
+ )
+
+ /**
+ * An action button with two states, selected and unselected. When the button is pressed, the
+ * state changes automatically.
+ *
+ * @param image The drawable to be shown if the button is in unselected state.
+ * @param imageSelected The drawable to be shown if the button is in selected state.
+ * @param contentDescription The content description to use if the button is in unselected state.
+ * @param contentDescriptionSelected The content description to use if the button is in selected state.
+ * @param visible Lambda that returns true or false to indicate whether this button should be shown.
+ * @param weight Lambda that returns an integer to indicate weight of an action. The lesser the weight,
+ * the closer it is to the url. A default weight -1 indicates, the position is not cared for
+ * and action will be appended at the end.
+ * @param selected Sets whether this button should be selected initially.
+ * @param background A custom (stateful) background drawable resource to be used.
+ * @param padding a custom [Padding] for this Button.
+ * @param listener Callback that will be invoked whenever the checked state changes.
+ */
+ open class ToggleButton(
+ image: Drawable,
+ imageSelected: Drawable,
+ contentDescription: String,
+ contentDescriptionSelected: String,
+ visible: () -> Boolean = { true },
+ weight: () -> Int = { -1 },
+ selected: Boolean = false,
+ @DrawableRes background: Int = 0,
+ val padding: Padding = DEFAULT_PADDING,
+ listener: (Boolean) -> Unit,
+ ) : Toolbar.ActionToggleButton(
+ image,
+ imageSelected,
+ contentDescription,
+ contentDescriptionSelected,
+ visible,
+ weight,
+ selected,
+ background,
+ padding,
+ listener,
+ )
+
+ /**
+ * An action that either shows an active button or an inactive button based on the provided
+ * isInPrimaryState lambda. All secondary characteristics default to their
+ * corresponding primary.
+ *
+ * @param primaryImage: The drawable to be shown if the button is in the primary/enabled state
+ * @param primaryContentDescription: The content description to use if the button is in the primary state.
+ * @param secondaryImage: The drawable to be shown if the button is in the secondary/disabled state.
+ * @param secondaryContentDescription: The content description to use if the button is in the secondary state.
+ * @param isInPrimaryState: Lambda that returns whether this button should be in the primary or secondary state.
+ * @param primaryImageTintResource: Optional ID of color resource to tint the icon in the primary state.
+ * @param secondaryImageTintResource: ID of color resource to tint the icon in the secondary state.
+ * @param disableInSecondaryState: Disable the button entirely when in the secondary state?
+ * @param weight Lambda that returns an integer to indicate weight of an action. The lesser the weight,
+ * the closer it is to the url. A default weight -1 indicates, the position is not cared for
+ * and action will be appended at the end.
+ * @param background A custom (stateful) background drawable resource to be used.
+ * @param longClickListener Callback that will be invoked whenever the button is long-pressed.
+ * @param listener Callback that will be invoked whenever the button is pressed.
+ */
+ open class TwoStateButton(
+ val primaryImage: Drawable,
+ val primaryContentDescription: String,
+ val secondaryImage: Drawable = primaryImage,
+ val secondaryContentDescription: String = primaryContentDescription,
+ val isInPrimaryState: () -> Boolean = { true },
+ @ColorRes val primaryImageTintResource: Int = NO_ID,
+ @ColorRes val secondaryImageTintResource: Int = primaryImageTintResource,
+ val disableInSecondaryState: Boolean = true,
+ override val weight: () -> Int = { -1 },
+ background: Int = 0,
+ longClickListener: (() -> Unit)? = null,
+ listener: () -> Unit,
+ ) : Button(
+ primaryImage,
+ primaryContentDescription,
+ weight = weight,
+ background = background,
+ longClickListener = longClickListener,
+ listener = listener,
+ ) {
+ var enabled: Boolean = false
+ private set
+
+ override fun bind(view: View) {
+ enabled = isInPrimaryState.invoke()
+
+ val button = view as ImageButton
+ if (enabled) {
+ button.setImageDrawable(primaryImage)
+ button.contentDescription = primaryContentDescription
+ button.setTintResource(primaryImageTintResource)
+ button.isEnabled = true
+ } else {
+ button.setImageDrawable(secondaryImage)
+ button.contentDescription = secondaryContentDescription
+ button.setTintResource(secondaryImageTintResource)
+ button.isEnabled = !disableInSecondaryState
+ }
+ }
+ }
+
+ companion object {
+ internal const val ACTION_PADDING_DP = 16
+ internal val DEFAULT_PADDING =
+ Padding(ACTION_PADDING_DP, ACTION_PADDING_DP, ACTION_PADDING_DP, ACTION_PADDING_DP)
+ }
+}
+
+/**
+ * Wraps [filter] execution in a coroutine context, cancelling prior executions on every invocation.
+ * [coroutineContext] must be of type that doesn't propagate cancellation of its children upwards.
+ */
+class AsyncFilterListener(
+ private val urlView: AutocompleteView,
+ override val coroutineContext: CoroutineContext,
+ private val filter: suspend (String, AutocompleteDelegate) -> Unit,
+ private val uiContext: CoroutineContext = Dispatchers.Main,
+) : OnFilterListener, CoroutineScope {
+ override fun invoke(text: String) {
+ // We got a new input, so whatever past autocomplete queries we still have running are
+ // irrelevant. We cancel them, but do not depend on cancellation to take place.
+ coroutineContext.cancelChildren()
+
+ CoroutineScope(coroutineContext).launch {
+ filter(text, AsyncAutocompleteDelegate(urlView, this, uiContext))
+ }
+ }
+}
+
+/**
+ * An autocomplete delegate which is aware of its parent scope (to check for cancellations).
+ * Responsible for processing autocompletion results and discarding stale results when [urlView] moved on.
+ */
+private class AsyncAutocompleteDelegate(
+ private val urlView: AutocompleteView,
+ private val parentScope: CoroutineScope,
+ override val coroutineContext: CoroutineContext,
+ private val logger: Logger = Logger("AsyncAutocompleteDelegate"),
+) : AutocompleteDelegate, CoroutineScope {
+ override fun applyAutocompleteResult(result: AutocompleteResult, onApplied: () -> Unit) {
+ // Bail out if we were cancelled already.
+ if (!parentScope.isActive) {
+ logger.debug("Autocomplete request cancelled. Discarding results.")
+ return
+ }
+
+ // Process results on the UI dispatcher.
+ CoroutineScope(coroutineContext).launch {
+ // Ignore this result if the query is stale.
+ if (result.input == urlView.originalText.lowercase()) {
+ urlView.applyAutocompleteResult(
+ InlineAutocompleteEditText.AutocompleteResult(
+ text = result.text,
+ source = result.source,
+ totalItems = result.totalItems,
+ ),
+ )
+ onApplied()
+ } else {
+ logger.debug("Discarding stale autocomplete result.")
+ }
+ }
+ }
+
+ override fun noAutocompleteResult(input: String) {
+ // Bail out if we were cancelled already.
+ if (!parentScope.isActive) {
+ logger.debug("Autocomplete request cancelled. Discarding 'noAutocompleteResult'.")
+ return
+ }
+
+ // Process results on the UI thread.
+ CoroutineScope(coroutineContext).launch {
+ // Ignore this result if the query is stale.
+ if (input == urlView.originalText) {
+ urlView.noAutocompleteResult()
+ } else {
+ logger.debug("Discarding stale lack of autocomplete results.")
+ }
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/DisplayToolbar.kt b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/DisplayToolbar.kt
new file mode 100644
index 0000000000..66218febbb
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/DisplayToolbar.kt
@@ -0,0 +1,711 @@
+/* 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.browser.toolbar2.display
+
+import android.content.Context
+import android.graphics.Color
+import android.graphics.Typeface
+import android.graphics.drawable.Drawable
+import android.os.Build
+import android.util.TypedValue
+import android.view.View
+import android.view.accessibility.AccessibilityEvent
+import android.widget.ImageView
+import android.widget.ProgressBar
+import androidx.annotation.ColorInt
+import androidx.appcompat.content.res.AppCompatResources.getDrawable
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.constraintlayout.widget.ConstraintSet
+import androidx.core.content.ContextCompat
+import androidx.core.view.isVisible
+import mozilla.components.browser.menu.BrowserMenuBuilder
+import mozilla.components.browser.toolbar2.BrowserToolbar
+import mozilla.components.browser.toolbar2.R
+import mozilla.components.browser.toolbar2.internal.ActionContainer
+import mozilla.components.concept.menu.MenuController
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.support.ktx.android.content.isScreenReaderEnabled
+import mozilla.components.ui.colors.R.color as photonColors
+
+/**
+ * Sub-component of the browser toolbar responsible for displaying the URL and related controls ("display mode").
+ *
+ * Structure:
+ * ```
+ * +-------------+------------+-----------------------+----------+------+
+ * | navigation | indicators | url [ page ] | browser | menu |
+ * | actions | | [ actions ] | actions | |
+ * +-------------+------------+-----------------------+----------+------+
+ * +------------------------progress------------------------------------+
+ * ```
+ *
+ * Navigation actions (optional):
+ * A dynamic list of clickable icons usually used for navigation on larger devices
+ * (e.g. “back”/”forward” buttons.)
+ *
+ * Indicators (optional):
+ * Tracking protection indicator icon (e.g. “shield” icon) that may show a doorhanger when clicked.
+ * Separator icon: a vertical line that separate the above and below icons.
+ * Site security indicator icon (e.g. “Lock” icon) that may show a doorhanger when clicked.
+ * Empty indicator: Icon that will be displayed if the URL is empty.
+ *
+ * URL:
+ * Section that displays the current URL (read-only)
+ *
+ * Page actions (optional):
+ * A dynamic list of clickable icons inside the URL section (e.g. “reader mode” icon)
+ *
+ * Browser actions (optional):
+ * A list of dynamic clickable icons on the toolbar (e.g. tabs tray button)
+ *
+ * Menu (optional):
+ * A button that shows an overflow menu when clicked (constructed using the browser-menu
+ * component)
+ *
+ * Progress (optional):
+ * A horizontal progress bar showing the loading progress (at the top or bottom of the toolbar).
+ */
+@Suppress("LargeClass")
+class DisplayToolbar internal constructor(
+ private val context: Context,
+ private val toolbar: BrowserToolbar,
+ internal val rootView: View,
+) {
+ /**
+ * Enum of indicators that can be displayed in the toolbar.
+ */
+ enum class Indicators {
+ SECURITY,
+ TRACKING_PROTECTION,
+ EMPTY,
+ HIGHLIGHT,
+ }
+
+ /**
+ * Data class holding the customizable colors in "display mode".
+ *
+ * @property securityIconSecure Color tint for the "secure connection" icon (lock).
+ * @property securityIconInsecure Color tint for the "insecure connection" icon (broken lock).
+ * @property emptyIcon Color tint for the icon shown when the URL is empty.
+ * @property menu Color tint for the menu icon.
+ * @property hint Text color of the hint shown when the URL is empty.
+ * @property title Text color of the website title.
+ * @property text Text color of the URL.
+ * @property trackingProtection Color tint for the tracking protection icons.
+ * @property separator Color tint for the separator shown between indicators.
+ * @property pageActionSeparator Color tint of separator dividing url and page actions.
+ * @property highlight Color tint for the highlight icon.
+ *
+ * Set/Get the site security icon colours. It uses a pair of color integers which represent the
+ * insecure and secure colours respectively.
+ */
+ data class Colors(
+ @ColorInt val securityIconSecure: Int,
+ @ColorInt val securityIconInsecure: Int,
+ @ColorInt val emptyIcon: Int,
+ @ColorInt val menu: Int,
+ @ColorInt val hint: Int,
+ @ColorInt val title: Int,
+ @ColorInt val text: Int,
+ @ColorInt val trackingProtection: Int?,
+ @ColorInt val separator: Int,
+ @ColorInt val pageActionSeparator: Int,
+ @ColorInt val highlight: Int?,
+ )
+
+ /**
+ * Data class holding the customizable icons in "display mode".
+ *
+ * @property emptyIcon An icon that is shown in front of the URL if the URL is empty.
+ * @property trackingProtectionTrackersBlocked An icon that is shown if tracking protection is
+ * enabled and trackers have been blocked.
+ * @property trackingProtectionNothingBlocked An icon that is shown if tracking protection is
+ * enabled and no trackers have been blocked.
+ * @property trackingProtectionException An icon that is shown if tracking protection is enabled
+ * but the current page is in the "exception list".
+ * @property highlight An icon that is shown if any event needs to be brought
+ * to the user's attention. Like the autoplay permission been blocked.
+ */
+ data class Icons(
+ val emptyIcon: Drawable?,
+ val trackingProtectionTrackersBlocked: Drawable,
+ val trackingProtectionNothingBlocked: Drawable,
+ val trackingProtectionException: Drawable,
+ val highlight: Drawable,
+ )
+
+ /**
+ * Gravity enum for positioning the progress bar.
+ */
+ enum class Gravity {
+ TOP,
+ BOTTOM,
+ }
+
+ internal val views = DisplayToolbarViews(
+ browserActions = rootView.findViewById(R.id.mozac_browser_toolbar_browser_actions),
+ pageActions = rootView.findViewById(R.id.mozac_browser_toolbar_page_actions),
+ navigationActions = rootView.findViewById(R.id.mozac_browser_toolbar_navigation_actions),
+ background = rootView.findViewById(R.id.mozac_browser_toolbar_background),
+ separator = rootView.findViewById(R.id.mozac_browser_toolbar_separator),
+ pageActionSeparator = rootView.findViewById(R.id.mozac_browser_toolbar_action_separator),
+ emptyIndicator = rootView.findViewById(R.id.mozac_browser_toolbar_empty_indicator),
+ menu = MenuButton(rootView.findViewById(R.id.mozac_browser_toolbar_menu)),
+ securityIndicator = rootView.findViewById(R.id.mozac_browser_toolbar_security_indicator),
+ trackingProtectionIndicator = rootView.findViewById(
+ R.id.mozac_browser_toolbar_tracking_protection_indicator,
+ ),
+ origin = rootView.findViewById(R.id.mozac_browser_toolbar_origin_view).also {
+ it.toolbar = toolbar
+ },
+ progress = rootView.findViewById(R.id.mozac_browser_toolbar_progress),
+ highlight = rootView.findViewById(R.id.mozac_browser_toolbar_permission_indicator),
+ )
+
+ /**
+ * Customizable colors in "display mode".
+ */
+ var colors: Colors = Colors(
+ securityIconSecure = ContextCompat.getColor(context, photonColors.photonWhite),
+ securityIconInsecure = ContextCompat.getColor(context, photonColors.photonWhite),
+ emptyIcon = ContextCompat.getColor(context, photonColors.photonWhite),
+ menu = ContextCompat.getColor(context, photonColors.photonWhite),
+ hint = views.origin.hintColor,
+ title = views.origin.titleColor,
+ text = views.origin.textColor,
+ trackingProtection = null,
+ separator = ContextCompat.getColor(context, photonColors.photonGrey80),
+ pageActionSeparator = ContextCompat.getColor(context, photonColors.photonGrey80),
+ highlight = null,
+ )
+ set(value) {
+ field = value
+
+ updateSiteSecurityIcon()
+ views.emptyIndicator.setColorFilter(value.emptyIcon)
+ views.menu.setColorFilter(value.menu)
+ views.origin.hintColor = value.hint
+ views.origin.titleColor = value.title
+ views.origin.textColor = value.text
+ views.separator.setColorFilter(value.separator)
+ views.pageActionSeparator.setBackgroundColor(value.pageActionSeparator)
+
+ if (value.trackingProtection != null) {
+ views.trackingProtectionIndicator.setTint(value.trackingProtection)
+ views.trackingProtectionIndicator.setColorFilter(value.trackingProtection)
+ }
+
+ if (value.highlight != null) {
+ views.highlight.setTint(value.highlight)
+ }
+ }
+
+ /**
+ * Customizable icons in "edit mode".
+ */
+ var icons: Icons = Icons(
+ emptyIcon = null,
+ trackingProtectionTrackersBlocked = requireNotNull(
+ getDrawable(context, TrackingProtectionIconView.DEFAULT_ICON_ON_TRACKERS_BLOCKED),
+ ),
+ trackingProtectionNothingBlocked = requireNotNull(
+ getDrawable(context, TrackingProtectionIconView.DEFAULT_ICON_ON_NO_TRACKERS_BLOCKED),
+ ),
+ trackingProtectionException = requireNotNull(
+ getDrawable(context, TrackingProtectionIconView.DEFAULT_ICON_OFF_FOR_A_SITE),
+ ),
+ highlight = requireNotNull(
+ getDrawable(context, R.drawable.mozac_dot_notification),
+ ),
+ )
+ set(value) {
+ field = value
+
+ views.emptyIndicator.setImageDrawable(value.emptyIcon)
+
+ views.trackingProtectionIndicator.setIcons(
+ value.trackingProtectionNothingBlocked,
+ value.trackingProtectionTrackersBlocked,
+ value.trackingProtectionException,
+ )
+ views.highlight.setIcon(value.highlight)
+ }
+
+ /**
+ * Allows customization of URL for display purposes.
+ */
+ var urlFormatter: ((CharSequence) -> CharSequence)? = null
+
+ /**
+ * Sets a listener to be invoked when the site security indicator icon is clicked.
+ */
+ fun setOnSiteSecurityClickedListener(listener: (() -> Unit)?) {
+ if (listener == null) {
+ views.securityIndicator.setOnClickListener(null)
+ views.securityIndicator.background = null
+ } else {
+ views.securityIndicator.setOnClickListener {
+ listener.invoke()
+ }
+
+ val outValue = TypedValue()
+ context.theme.resolveAttribute(
+ android.R.attr.selectableItemBackgroundBorderless,
+ outValue,
+ true,
+ )
+
+ views.securityIndicator.setBackgroundResource(outValue.resourceId)
+ }
+ }
+
+ /**
+ * Sets a listener to be invoked when the site tracking protection indicator icon is clicked.
+ */
+ fun setOnTrackingProtectionClickedListener(listener: (() -> Unit)?) {
+ if (listener == null) {
+ views.trackingProtectionIndicator.setOnClickListener(null)
+ views.trackingProtectionIndicator.background = null
+ } else {
+ views.trackingProtectionIndicator.setOnClickListener {
+ listener.invoke()
+ }
+
+ val outValue = TypedValue()
+ context.theme.resolveAttribute(
+ android.R.attr.selectableItemBackgroundBorderless,
+ outValue,
+ true,
+ )
+
+ views.trackingProtectionIndicator.setBackgroundResource(outValue.resourceId)
+ }
+ }
+
+ /**
+ * Sets a lambda to be invoked when the menu is dismissed
+ */
+ fun setMenuDismissAction(onDismiss: () -> Unit) {
+ views.menu.setMenuDismissAction(onDismiss)
+ }
+
+ /**
+ * List of indicators that should be displayed next to the URL.
+ */
+ var indicators: List = listOf(Indicators.SECURITY)
+ set(value) {
+ field = value
+
+ updateIndicatorVisibility()
+ }
+
+ var displayIndicatorSeparator: Boolean = true
+ set(value) {
+ field = value
+ updateIndicatorVisibility()
+ }
+
+ /**
+ * Sets the background that should be drawn behind the URL, page actions an indicators.
+ */
+ fun setUrlBackground(background: Drawable?) {
+ views.background.setImageDrawable(background)
+ }
+
+ /**
+ * Whether the progress bar should be drawn at the top or bottom of the toolbar.
+ */
+ var progressGravity: Gravity = Gravity.BOTTOM
+ set(value) {
+ field = value
+
+ val layout = rootView as ConstraintLayout
+ layout.hashCode()
+
+ val constraintSet = ConstraintSet()
+ constraintSet.clone(layout)
+ constraintSet.clear(views.progress.id, ConstraintSet.TOP)
+ constraintSet.clear(views.progress.id, ConstraintSet.BOTTOM)
+ constraintSet.connect(
+ views.progress.id,
+ if (value == Gravity.TOP) ConstraintSet.TOP else ConstraintSet.BOTTOM,
+ ConstraintSet.PARENT_ID,
+ if (value == Gravity.TOP) ConstraintSet.TOP else ConstraintSet.BOTTOM,
+ )
+ constraintSet.applyTo(layout)
+ }
+
+ /**
+ * Sets a lambda that will be invoked whenever the URL in display mode was clicked. Only if this
+ * lambda returns true the toolbar will switch to editing mode. Return
+ * false to not switch to editing mode and handle the click manually.
+ */
+ var onUrlClicked: () -> Boolean
+ get() = views.origin.onUrlClicked
+ set(value) {
+ views.origin.onUrlClicked = value
+ }
+
+ /**
+ * Sets the text to be displayed when the URL of the toolbar is empty.
+ */
+ var hint: String
+ get() = views.origin.hint
+ set(value) {
+ views.origin.hint = value
+ }
+
+ /**
+ * Sets the size of the text for the title displayed in the toolbar.
+ */
+ var titleTextSize: Float
+ get() = views.origin.titleTextSize
+ set(value) {
+ views.origin.titleTextSize = value
+ }
+
+ /**
+ * Sets the size of the text for the URL/search term displayed in the toolbar.
+ */
+ var textSize: Float
+ get() = views.origin.textSize
+ set(value) {
+ views.origin.textSize = value
+ }
+
+ /**
+ * Sets the typeface of the text for the URL/search term displayed in the toolbar.
+ */
+ var typeface: Typeface
+ get() = views.origin.typeface
+ set(value) {
+ views.origin.typeface = value
+ }
+
+ /**
+ * Sets a [BrowserMenuBuilder] that will be used to create a menu when the menu button is clicked.
+ * The menu button will only be visible if a builder or controller has been set.
+ */
+ var menuBuilder: BrowserMenuBuilder?
+ get() = views.menu.menuBuilder
+ set(value) {
+ views.menu.menuBuilder = value
+ }
+
+ /**
+ * Sets a [MenuController] that will be used to create a menu when the menu button is clicked.
+ * The menu button will only be visible if a builder or controller has been set.
+ * If both a [menuBuilder] and controller are present, only the controller will be used.
+ */
+ var menuController: MenuController?
+ get() = views.menu.menuController
+ set(value) {
+ views.menu.menuController = value
+ }
+
+ /**
+ * Set a LongClickListener to the urlView of the toolbar.
+ */
+ fun setOnUrlLongClickListener(handler: ((View) -> Boolean)?) = views.origin.setOnUrlLongClickListener(handler)
+
+ private fun updateIndicatorVisibility() {
+ val urlEmpty = url.isEmpty()
+
+ views.securityIndicator.visibility = if (!urlEmpty && indicators.contains(Indicators.SECURITY)) {
+ View.VISIBLE
+ } else {
+ View.GONE
+ }
+
+ views.trackingProtectionIndicator.visibility = if (
+ !urlEmpty && indicators.contains(Indicators.TRACKING_PROTECTION)
+ ) {
+ View.VISIBLE
+ } else {
+ View.GONE
+ }
+
+ views.emptyIndicator.visibility = if (urlEmpty && indicators.contains(Indicators.EMPTY)) {
+ View.VISIBLE
+ } else {
+ View.GONE
+ }
+
+ views.highlight.visibility = if (!urlEmpty && indicators.contains(Indicators.HIGHLIGHT)) {
+ setHighlight(toolbar.highlight)
+ } else {
+ View.GONE
+ }
+
+ updateSeparatorVisibility()
+ }
+
+ private fun updateSeparatorVisibility() {
+ views.separator.visibility = if (
+ displayIndicatorSeparator &&
+ views.trackingProtectionIndicator.isVisible &&
+ views.securityIndicator.isVisible
+ ) {
+ View.VISIBLE
+ } else {
+ View.GONE
+ }
+
+ // In Fenix (which is using a beta release of ConstraintLayout) we are seeing issues after
+ // early visibility changes. Children of the ConstraintLayout are not visible and have a
+ // size of 0x0 (even though they have a fixed size in the layout XML). Explicitly requesting
+ // to layout the ConstraintLayout fixes that issue. This may be a bug in the beta of
+ // ConstraintLayout and in the future we may be able to just remove this call.
+ rootView.requestLayout()
+ }
+
+ /**
+ * Updates the title to be displayed.
+ */
+ internal var title: String
+ get() = views.origin.title
+ set(value) {
+ views.origin.title = value
+ }
+
+ /**
+ * Updates the URL to be displayed.
+ */
+ internal var url: CharSequence = ""
+ set(value) {
+ field = value
+ views.origin.url = urlFormatter?.invoke(value) ?: value
+ updateIndicatorVisibility()
+ }
+
+ /**
+ * Sets the site's security icon as secure if true, else the regular globe.
+ */
+ internal var siteSecurity: Toolbar.SiteSecurity = Toolbar.SiteSecurity.INSECURE
+ set(value) {
+ field = value
+ updateSiteSecurityIcon()
+ }
+
+ private fun updateSiteSecurityIcon() {
+ @ColorInt val color = when (siteSecurity) {
+ Toolbar.SiteSecurity.INSECURE -> colors.securityIconInsecure
+ Toolbar.SiteSecurity.SECURE -> colors.securityIconSecure
+ }
+ if (color == Color.TRANSPARENT && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ views.securityIndicator.clearColorFilter()
+ } else {
+ views.securityIndicator.setColorFilter(color)
+ }
+
+ views.securityIndicator.siteSecurity = siteSecurity
+ }
+
+ internal fun setTrackingProtectionState(state: Toolbar.SiteTrackingProtection) {
+ views.trackingProtectionIndicator.siteTrackingProtection = state
+ updateSeparatorVisibility()
+ }
+
+ internal fun setHighlight(state: Toolbar.Highlight): Int {
+ if (!indicators.contains(Indicators.HIGHLIGHT)) {
+ return views.highlight.visibility
+ }
+
+ views.highlight.state = state
+
+ return views.highlight.visibility
+ }
+
+ internal fun onStop() {
+ views.menu.dismissMenu()
+ }
+
+ /**
+ * Updates the progress to be displayed.
+ *
+ * Accessibility note:
+ * ProgressBars can be made accessible to TalkBack by setting `android:accessibilityLiveRegion`.
+ * They will emit TYPE_VIEW_SELECTED events. TalkBack will format those events into percentage
+ * announcements along with a pitch-change earcon. We are not using that feature here for
+ * several reasons:
+ * 1. They are dispatched via a 200ms timeout. Since loading a page can be a short process,
+ * and since we only update the bar a handful of times, these events often never fire and
+ * they don't give the user a true sense of the progress.
+ * 2. The last 100% event is dispatched after the view is hidden. This prevents the event
+ * from being fired, so the user never gets a "complete" event.
+ * 3. Live regions in TalkBack have their role announced, so the user will hear
+ * "Progress bar, 25%". For a common feature like page load this is very chatty and unintuitive.
+ * 4. We can provide custom strings instead of the less useful percentage utterance, but
+ * TalkBack will not play an earcon if an event has its own text.
+ *
+ * For all those reasons, we are going another route here with a "loading" announcement
+ * when the progress bar first appears along with scroll events that have the same
+ * pitch-change earcon in TalkBack (although they are a bit louder). This gives a concise and
+ * consistent feedback to the user that they can depend on.
+ *
+ */
+ internal fun updateProgress(progress: Int) {
+ if (!views.progress.isVisible && progress > 0) {
+ // Loading has just started, make visible.
+ views.progress.visibility = View.VISIBLE
+
+ // Announce "loading" for accessibility if it has not been completed
+ if (progress < views.progress.max) {
+ views.progress.announceForAccessibility(
+ context.getString(R.string.mozac_browser_toolbar_progress_loading),
+ )
+ }
+ }
+
+ views.progress.progress = progress
+ val event = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ AccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SCROLLED)
+ } else {
+ @Suppress("DEPRECATION")
+ AccessibilityEvent.obtain(AccessibilityEvent.TYPE_VIEW_SCROLLED)
+ }.apply {
+ scrollY = progress
+ maxScrollY = views.progress.max
+ }
+
+ if (context.isScreenReaderEnabled) {
+ views.progress.parent.requestSendAccessibilityEvent(views.progress, event)
+ }
+
+ if (progress >= views.progress.max) {
+ // Loading is done, hide progress bar.
+ views.progress.visibility = View.GONE
+ }
+ }
+
+ /**
+ * Declare that the actions (navigation actions, browser actions, page actions) have changed and
+ * should be updated if needed.
+ */
+ internal fun invalidateActions() {
+ views.menu.invalidateMenu()
+
+ views.browserActions.invalidateActions()
+ views.pageActions.invalidateActions()
+ views.navigationActions.invalidateActions()
+ }
+
+ /**
+ * Adds an action to be displayed on the right side of the toolbar (outside of the URL bounding
+ * box) in display mode.
+ *
+ * If there is not enough room to show all icons then some icons may be moved to an overflow
+ * menu.
+ *
+ * Related:
+ * https://developer.mozilla.org/en-US/Add-ons/WebExtensions/user_interface/Browser_action
+ */
+ internal fun addBrowserAction(action: Toolbar.Action) {
+ views.browserActions.addAction(action)
+ }
+
+ /**
+ * Removes a previously added browser action (see [addBrowserAction]). If the provided
+ * action was never added, this method has no effect.
+ *
+ * @param action the action to remove.
+ */
+ internal fun removeBrowserAction(action: Toolbar.Action) {
+ views.browserActions.removeAction(action)
+ }
+
+ /**
+ * Removes a previously added page action (see [addBrowserAction]). If the provided
+ * action was never added, this method has no effect.
+ *
+ * @param action the action to remove.
+ */
+ internal fun removePageAction(action: Toolbar.Action) {
+ views.pageActions.removeAction(action)
+ }
+
+ /**
+ * Adds an action to be displayed on the right side of the URL in display mode.
+ *
+ * Related:
+ * https://developer.mozilla.org/en-US/Add-ons/WebExtensions/user_interface/Page_actions
+ */
+ internal fun addPageAction(action: Toolbar.Action) {
+ views.pageActions.addAction(action)
+ }
+
+ /**
+ * Adds an action to be display on the far left side of the toolbar. This area is usually used
+ * on larger devices for navigation actions like "back" and "forward".
+ */
+ internal fun addNavigationAction(action: Toolbar.Action) {
+ views.navigationActions.addAction(action)
+ }
+
+ /**
+ * Removes a previously added navigation action (see [addNavigationAction]). If the provided
+ * action was never added, this method has no effect.
+ *
+ * @param action the action to remove.
+ */
+ internal fun removeNavigationAction(action: Toolbar.Action) {
+ views.navigationActions.removeAction(action)
+ }
+
+ /**
+ * Hides the menu button in display mode.
+ */
+ fun hideMenuButton() {
+ views.menu.setShouldBeHidden(true)
+ }
+
+ /**
+ * Shows the menu button in display mode.
+ */
+ internal fun showMenuButton() {
+ views.menu.setShouldBeHidden(false)
+ }
+
+ /**
+ * Sets the horizontal padding.
+ */
+ fun setHorizontalPadding(horizontalPadding: Int) {
+ rootView.setPadding(horizontalPadding, 0, horizontalPadding, 0)
+ }
+
+ /**
+ * Hides the page action separator in display mode.
+ */
+ fun hidePageActionSeparator() {
+ views.pageActionSeparator.isVisible = false
+ }
+
+ /**
+ * Shows the page action separator in display mode.
+ */
+ internal fun showPageActionSeparator() {
+ views.pageActionSeparator.isVisible = true
+ }
+}
+
+/**
+ * Internal holder for view references.
+ */
+@Suppress("LongParameterList")
+internal class DisplayToolbarViews(
+ val browserActions: ActionContainer,
+ val pageActions: ActionContainer,
+ val navigationActions: ActionContainer,
+ val background: ImageView,
+ val separator: ImageView,
+ val pageActionSeparator: View,
+ val emptyIndicator: ImageView,
+ val menu: MenuButton,
+ val securityIndicator: SiteSecurityIconView,
+ val trackingProtectionIndicator: TrackingProtectionIconView,
+ val origin: OriginView,
+ val progress: ProgressBar,
+ val highlight: HighlightView,
+)
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/DisplayToolbarView.kt b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/DisplayToolbarView.kt
new file mode 100644
index 0000000000..5bd684a07e
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/DisplayToolbarView.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.browser.toolbar2.display
+
+import android.content.Context
+import android.graphics.Canvas
+import android.util.AttributeSet
+import android.view.View
+import android.widget.ImageView
+import androidx.constraintlayout.widget.ConstraintLayout
+import mozilla.components.browser.toolbar2.R
+
+/**
+ * Custom ConstraintLayout for DisplayToolbar that allows us to draw ripple backgrounds on the toolbar
+ * by setting a background to transparent.
+ */
+class DisplayToolbarView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+) : ConstraintLayout(context, attrs, defStyleAttr) {
+ init {
+ // Forcing transparent background so that draw methods will get called and ripple effect
+ // for children will be drawn on this layout.
+ setBackgroundColor(0x00000000)
+ }
+
+ lateinit var backgroundView: ImageView
+
+ override fun onFinishInflate() {
+ backgroundView = findViewById(R.id.mozac_browser_toolbar_background)
+ backgroundView.visibility = View.INVISIBLE
+
+ super.onFinishInflate()
+ }
+
+ // Overriding draw instead of onDraw since we want to draw the background before the actual
+ // (transparent) background (with a ripple effect) is drawn.
+ override fun draw(canvas: Canvas) {
+ canvas.save()
+ canvas.translate(backgroundView.x, backgroundView.y)
+
+ backgroundView.drawable?.draw(canvas)
+
+ canvas.restore()
+
+ super.draw(canvas)
+ }
+}
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/HighlightView.kt b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/HighlightView.kt
new file mode 100644
index 0000000000..240da038ec
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/HighlightView.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.browser.toolbar2.display
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import androidx.annotation.VisibleForTesting
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.core.view.isVisible
+import mozilla.components.browser.toolbar2.R
+import mozilla.components.concept.toolbar.Toolbar.Highlight
+import mozilla.components.concept.toolbar.Toolbar.Highlight.NONE
+import mozilla.components.concept.toolbar.Toolbar.Highlight.PERMISSIONS_CHANGED
+
+/**
+ * Internal widget to display a dot notification.
+ */
+internal class HighlightView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+) : AppCompatImageView(context, attrs, defStyleAttr) {
+
+ init {
+ visibility = GONE
+ }
+
+ var state: Highlight = NONE
+ set(value) {
+ if (value != field) {
+ field = value
+ updateIcon()
+ }
+ }
+
+ @VisibleForTesting
+ internal var highlightTint: Int? = null
+
+ private var highlightIcon: Drawable =
+ requireNotNull(AppCompatResources.getDrawable(context, DEFAULT_ICON))
+
+ fun setTint(tint: Int) {
+ highlightTint = tint
+ setColorFilter(tint)
+ }
+
+ fun setIcon(icons: Drawable) {
+ this.highlightIcon = icons
+
+ updateIcon()
+ }
+
+ @Synchronized
+ @VisibleForTesting
+ internal fun updateIcon() {
+ val update = state.toUpdate()
+
+ isVisible = update.visible
+
+ contentDescription = if (update.contentDescription != null) {
+ context.getString(update.contentDescription)
+ } else {
+ null
+ }
+
+ highlightTint?.let { setColorFilter(it) }
+ setImageDrawable(update.drawable)
+ }
+
+ companion object {
+ val DEFAULT_ICON = R.drawable.mozac_dot_notification
+ }
+
+ private fun Highlight.toUpdate(): Update = when (this) {
+ PERMISSIONS_CHANGED -> Update(
+ highlightIcon,
+ R.string.mozac_browser_toolbar_content_description_autoplay_blocked,
+ true,
+ )
+
+ NONE -> Update(
+ null,
+ null,
+ false,
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/MenuButton.kt b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/MenuButton.kt
new file mode 100644
index 0000000000..6c2a919d00
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/MenuButton.kt
@@ -0,0 +1,106 @@
+/* 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.browser.toolbar2.display
+
+import androidx.annotation.ColorInt
+import androidx.annotation.VisibleForTesting
+import androidx.core.view.isVisible
+import mozilla.components.browser.menu.BrowserMenuBuilder
+import mozilla.components.browser.menu.ext.asCandidateList
+import mozilla.components.browser.menu.ext.getHighlight
+import mozilla.components.browser.toolbar2.facts.emitOpenMenuFact
+import mozilla.components.concept.menu.MenuController
+
+internal class MenuButton(
+ @get:VisibleForTesting internal val impl: mozilla.components.browser.menu.view.MenuButton,
+) {
+
+ init {
+ impl.isVisible = false
+ impl.register(
+ object : mozilla.components.concept.menu.MenuButton.Observer {
+ override fun onShow() {
+ emitOpenMenuFact(impl.menuBuilder?.extras)
+ }
+ },
+ )
+ }
+
+ /**
+ * Reference to the [MenuController].
+ * If present, [menuBuilder] will be ignored.
+ */
+ var menuController: MenuController?
+ get() = impl.menuController
+ set(value) {
+ impl.menuController = value
+ impl.isVisible = shouldBeVisible()
+ }
+
+ /**
+ * Legacy [BrowserMenuBuilder] reference.
+ * Used to build the menu.
+ */
+ var menuBuilder: BrowserMenuBuilder?
+ get() = impl.menuBuilder
+ set(value) {
+ impl.menuBuilder = value
+ impl.isVisible = shouldBeVisible()
+ }
+
+ /**
+ * Declare that the menu items should be updated if needed.
+ * This should only be used once a [menuBuilder] is set.
+ * To update items in the [menuController], call [MenuController.submitList] directly.
+ */
+ fun invalidateMenu() {
+ val menuController = menuController
+ if (menuController != null) {
+ // Convert the menu builder items into a menu candidate list,
+ // if the menu builder is present
+ menuBuilder?.items?.let { items ->
+ val list = items.asCandidateList(impl.context)
+ menuController.submitList(list)
+ }
+ } else {
+ // Invalidate the BrowserMenu
+ impl.invalidateBrowserMenu()
+ impl.setHighlight(menuBuilder?.items?.getHighlight())
+ }
+ }
+
+ fun dismissMenu() {
+ val menuController = menuController
+ if (menuController != null) {
+ // Use the controller to dismiss the menu
+ menuController.dismiss()
+ } else {
+ // Use the button to dismiss the legacy menu
+ impl.dismissMenu()
+ }
+ }
+
+ /**
+ * Sets a lambda to be invoked when the menu is dismissed
+ */
+ @Suppress("Deprecation")
+ fun setMenuDismissAction(onDismiss: () -> Unit) {
+ impl.onDismiss = onDismiss
+ }
+
+ fun setColorFilter(@ColorInt color: Int) = impl.setColorFilter(color)
+
+ /**
+ * Hides the menu button.
+ *
+ * @param shouldBeHidden A [Boolean] that determines the visibility of the menu button.
+ */
+ fun setShouldBeHidden(shouldBeHidden: Boolean) {
+ impl.isVisible = !shouldBeHidden && shouldBeVisible()
+ }
+
+ @VisibleForTesting
+ internal fun shouldBeVisible() = impl.menuBuilder != null || impl.menuController != null
+}
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/OriginView.kt b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/OriginView.kt
new file mode 100644
index 0000000000..3cd70d0dc9
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/OriginView.kt
@@ -0,0 +1,198 @@
+/* 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.browser.toolbar2.display
+
+import android.animation.LayoutTransition
+import android.content.Context
+import android.graphics.Typeface
+import android.util.AttributeSet
+import android.util.TypedValue
+import android.view.Gravity
+import android.view.View
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.annotation.VisibleForTesting
+import androidx.core.view.isVisible
+import mozilla.components.browser.toolbar2.BrowserToolbar
+import mozilla.components.browser.toolbar2.R
+
+private const val TITLE_VIEW_WEIGHT = 5.7f
+private const val URL_VIEW_WEIGHT = 4.3f
+
+/**
+ * View displaying the URL and optionally the title of a website.
+ */
+internal class OriginView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+) : LinearLayout(context, attrs, defStyleAttr) {
+ internal lateinit var toolbar: BrowserToolbar
+
+ private val textSizeUrlNormal = context.resources.getDimension(
+ R.dimen.mozac_browser_toolbar_url_textsize,
+ )
+ private val textSizeUrlWithTitle = context.resources.getDimension(
+ R.dimen.mozac_browser_toolbar_url_with_title_textsize,
+ )
+ private val textSizeTitle = context.resources.getDimension(
+ R.dimen.mozac_browser_toolbar_title_textsize,
+ )
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal val urlView = TextView(context).apply {
+ id = R.id.mozac_browser_toolbar_url_view
+ setTextSize(TypedValue.COMPLEX_UNIT_PX, textSizeUrlNormal)
+ gravity = Gravity.CENTER_VERTICAL
+
+ setSingleLine()
+ isClickable = true
+ isFocusable = true
+
+ setOnClickListener {
+ if (onUrlClicked()) {
+ toolbar.editMode()
+ }
+ }
+
+ val fadingEdgeSize = resources.getDimensionPixelSize(
+ R.dimen.mozac_browser_toolbar_url_fading_edge_size,
+ )
+
+ setFadingEdgeLength(fadingEdgeSize)
+ isHorizontalFadingEdgeEnabled = fadingEdgeSize > 0
+ }
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal val titleView = TextView(context).apply {
+ id = R.id.mozac_browser_toolbar_title_view
+ visibility = View.GONE
+
+ setTextSize(
+ TypedValue.COMPLEX_UNIT_PX,
+ textSizeTitle,
+ )
+ gravity = Gravity.CENTER_VERTICAL
+
+ setSingleLine()
+
+ val fadingEdgeSize = resources.getDimensionPixelSize(
+ R.dimen.mozac_browser_toolbar_url_fading_edge_size,
+ )
+
+ setFadingEdgeLength(fadingEdgeSize)
+ isHorizontalFadingEdgeEnabled = fadingEdgeSize > 0
+ }
+
+ init {
+ orientation = VERTICAL
+
+ addView(
+ titleView,
+ LayoutParams(
+ LayoutParams.MATCH_PARENT,
+ 0,
+ TITLE_VIEW_WEIGHT,
+ ),
+ )
+
+ addView(
+ urlView,
+ LayoutParams(
+ LayoutParams.MATCH_PARENT,
+ 0,
+ URL_VIEW_WEIGHT,
+ ),
+ )
+
+ layoutTransition = LayoutTransition()
+ }
+
+ internal var title: String
+ get() = titleView.text.toString()
+ set(value) {
+ titleView.text = value
+
+ titleView.isVisible = value.isNotEmpty()
+
+ urlView.setTextSize(
+ TypedValue.COMPLEX_UNIT_PX,
+ if (value.isNotEmpty()) {
+ textSizeUrlWithTitle
+ } else {
+ textSizeUrlNormal
+ },
+ )
+ }
+
+ internal var onUrlClicked: () -> Boolean = { true }
+
+ fun setOnUrlLongClickListener(handler: ((View) -> Boolean)?) {
+ urlView.isLongClickable = true
+ titleView.isLongClickable = true
+
+ urlView.setOnLongClickListener(handler)
+ titleView.setOnLongClickListener(handler)
+ }
+
+ internal var url: CharSequence
+ get() = urlView.text
+ set(value) { urlView.text = value }
+
+ /**
+ * Sets the colour of the text to be displayed when the URL of the toolbar is empty.
+ */
+ var hintColor: Int
+ get() = urlView.currentHintTextColor
+ set(value) {
+ urlView.setHintTextColor(value)
+ }
+
+ /**
+ * Sets the text to be displayed when the URL of the toolbar is empty.
+ */
+ var hint: String
+ get() = urlView.hint.toString()
+ set(value) { urlView.hint = value }
+
+ /**
+ * Sets the colour of the text for title displayed in the toolbar.
+ */
+ var titleColor: Int
+ get() = urlView.currentTextColor
+ set(value) { titleView.setTextColor(value) }
+
+ /**
+ * Sets the colour of the text for the URL/search term displayed in the toolbar.
+ */
+ var textColor: Int
+ get() = urlView.currentTextColor
+ set(value) { urlView.setTextColor(value) }
+
+ /**
+ * Sets the size of the text for the title displayed in the toolbar.
+ */
+ var titleTextSize: Float
+ get() = titleView.textSize
+ set(value) { titleView.textSize = value }
+
+ /**
+ * Sets the size of the text for the URL/search term displayed in the toolbar.
+ */
+ var textSize: Float
+ get() = urlView.textSize
+ set(value) {
+ urlView.textSize = value
+ }
+
+ /**
+ * Sets the typeface of the text for the URL/search term displayed in the toolbar.
+ */
+ var typeface: Typeface
+ get() = urlView.typeface
+ set(value) {
+ urlView.typeface = value
+ }
+}
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/SiteSecurityIconView.kt b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/SiteSecurityIconView.kt
new file mode 100644
index 0000000000..6ebde5a885
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/SiteSecurityIconView.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.browser.toolbar2.display
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.View
+import androidx.appcompat.widget.AppCompatImageView
+import mozilla.components.browser.toolbar2.R
+import mozilla.components.concept.toolbar.Toolbar.SiteSecurity
+
+/**
+ * Internal widget to display the different icons of site security, relies on the
+ * [SiteSecurity] state of each page.
+ */
+internal class SiteSecurityIconView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+) : AppCompatImageView(context, attrs, defStyleAttr) {
+
+ // We allow null here because in some situations, onCreateDrawableState is getting called from
+ // the super() constructor on the View class, way before this class properties get
+ // initialized causing a null pointer exception.
+ // See for more details: https://github.com/mozilla-mobile/android-components/issues/4058
+ var siteSecurity: SiteSecurity? = SiteSecurity.INSECURE
+ set(value) {
+ if (value != field) {
+ field = value
+ refreshDrawableState()
+ }
+
+ field = value
+ }
+
+ override fun onCreateDrawableState(extraSpace: Int): IntArray {
+ return when (siteSecurity) {
+ SiteSecurity.INSECURE, null -> super.onCreateDrawableState(extraSpace)
+ SiteSecurity.SECURE -> {
+ val drawableState = super.onCreateDrawableState(extraSpace + 1)
+ View.mergeDrawableStates(drawableState, intArrayOf(R.attr.state_site_secure))
+ drawableState
+ }
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/TrackingProtectionIconView.kt b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/TrackingProtectionIconView.kt
new file mode 100644
index 0000000000..654f4edadb
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/display/TrackingProtectionIconView.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.browser.toolbar2.display
+
+import android.content.Context
+import android.graphics.drawable.Animatable
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import androidx.annotation.StringRes
+import androidx.annotation.VisibleForTesting
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.core.view.isVisible
+import mozilla.components.browser.toolbar2.R
+import mozilla.components.concept.toolbar.Toolbar.SiteTrackingProtection
+import mozilla.components.concept.toolbar.Toolbar.SiteTrackingProtection.OFF_FOR_A_SITE
+import mozilla.components.concept.toolbar.Toolbar.SiteTrackingProtection.OFF_GLOBALLY
+import mozilla.components.concept.toolbar.Toolbar.SiteTrackingProtection.ON_NO_TRACKERS_BLOCKED
+import mozilla.components.concept.toolbar.Toolbar.SiteTrackingProtection.ON_TRACKERS_BLOCKED
+
+/**
+ * Internal widget to display the different icons of tracking protection, relies on the
+ * [SiteTrackingProtection] state of each page.
+ */
+internal class TrackingProtectionIconView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+) : AppCompatImageView(context, attrs, defStyleAttr) {
+ var siteTrackingProtection: SiteTrackingProtection? = null
+ set(value) {
+ if (value != field) {
+ field = value
+ updateIcon()
+ }
+ }
+
+ @VisibleForTesting
+ internal var trackingProtectionTint: Int? = null
+
+ private var iconOnNoTrackersBlocked: Drawable =
+ requireNotNull(AppCompatResources.getDrawable(context, DEFAULT_ICON_ON_NO_TRACKERS_BLOCKED))
+ private var iconOnTrackersBlocked: Drawable =
+ requireNotNull(AppCompatResources.getDrawable(context, DEFAULT_ICON_ON_TRACKERS_BLOCKED))
+ private var disabledForSite: Drawable =
+ requireNotNull(AppCompatResources.getDrawable(context, DEFAULT_ICON_OFF_FOR_A_SITE))
+
+ fun setTint(tint: Int) {
+ trackingProtectionTint = tint
+ }
+
+ fun setIcons(
+ iconOnNoTrackersBlocked: Drawable,
+ iconOnTrackersBlocked: Drawable,
+ disabledForSite: Drawable,
+ ) {
+ this.iconOnNoTrackersBlocked = iconOnNoTrackersBlocked
+ this.iconOnTrackersBlocked = iconOnTrackersBlocked
+ this.disabledForSite = disabledForSite
+
+ updateIcon()
+ }
+
+ @Synchronized
+ private fun updateIcon() {
+ val update = siteTrackingProtection?.toUpdate() ?: return
+
+ isVisible = update.visible
+
+ contentDescription = if (update.contentDescription != null) {
+ context.getString(update.contentDescription)
+ } else {
+ null
+ }
+
+ setOrClearColorFilter(update.drawable)
+ setImageDrawable(update.drawable)
+
+ if (update.drawable is Animatable) {
+ update.drawable.start()
+ }
+ }
+
+ @VisibleForTesting
+ internal fun setOrClearColorFilter(drawable: Drawable?) {
+ if (drawable is Animatable) {
+ clearColorFilter()
+ } else {
+ trackingProtectionTint?.let { setColorFilter(it) }
+ }
+ }
+
+ companion object {
+ val DEFAULT_ICON_ON_NO_TRACKERS_BLOCKED =
+ R.drawable.mozac_ic_tracking_protection_on_no_trackers_blocked
+ val DEFAULT_ICON_ON_TRACKERS_BLOCKED =
+ R.drawable.mozac_ic_tracking_protection_on_trackers_blocked
+ val DEFAULT_ICON_OFF_FOR_A_SITE =
+ R.drawable.mozac_ic_tracking_protection_off_for_a_site
+ }
+
+ private fun SiteTrackingProtection.toUpdate(): Update = when (this) {
+ ON_NO_TRACKERS_BLOCKED -> Update(
+ iconOnNoTrackersBlocked,
+ R.string.mozac_browser_toolbar_content_description_tracking_protection_on_no_trackers_blocked,
+ true,
+ )
+
+ ON_TRACKERS_BLOCKED -> Update(
+ iconOnTrackersBlocked,
+ R.string.mozac_browser_toolbar_content_description_tracking_protection_on_trackers_blocked1,
+ true,
+ )
+
+ OFF_FOR_A_SITE -> Update(
+ disabledForSite,
+ R.string.mozac_browser_toolbar_content_description_tracking_protection_off_for_a_site1,
+ true,
+ )
+
+ OFF_GLOBALLY -> Update(
+ null,
+ null,
+ false,
+ )
+ }
+}
+
+internal class Update(
+ val drawable: Drawable?,
+ @StringRes val contentDescription: Int?,
+ val visible: Boolean,
+)
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/edit/EditToolbar.kt b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/edit/EditToolbar.kt
new file mode 100644
index 0000000000..2595ab66e2
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/edit/EditToolbar.kt
@@ -0,0 +1,415 @@
+/* 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.browser.toolbar2.edit
+
+import android.content.Context
+import android.graphics.Typeface
+import android.graphics.drawable.Drawable
+import android.os.Build
+import android.view.KeyEvent
+import android.view.View
+import android.widget.ImageView
+import androidx.annotation.ColorInt
+import androidx.annotation.VisibleForTesting
+import androidx.annotation.VisibleForTesting.Companion.PRIVATE
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.constraintlayout.widget.ConstraintSet
+import androidx.core.content.ContextCompat
+import androidx.core.view.inputmethod.EditorInfoCompat
+import androidx.core.view.isVisible
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.asCoroutineDispatcher
+import mozilla.components.browser.toolbar2.AsyncFilterListener
+import mozilla.components.browser.toolbar2.BrowserToolbar
+import mozilla.components.browser.toolbar2.R
+import mozilla.components.browser.toolbar2.facts.emitCommitFact
+import mozilla.components.browser.toolbar2.internal.ActionContainer
+import mozilla.components.concept.toolbar.AutocompleteDelegate
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.support.base.log.logger.Logger
+import mozilla.components.support.base.utils.NamedThreadFactory
+import mozilla.components.support.ktx.android.view.showKeyboard
+import mozilla.components.ui.autocomplete.InlineAutocompleteEditText
+import java.util.concurrent.Executors
+import mozilla.components.ui.colors.R as colorsR
+
+private const val AUTOCOMPLETE_QUERY_THREADS = 3
+
+/**
+ * Sub-component of the browser toolbar responsible for allowing the user to edit the URL ("edit mode").
+ *
+ * Structure:
+ * +------+--------------------+---------------------------+------------------+------+
+ * | icon | edit actions start | url | edit actions end | exit |
+ * +------+--------------------+---------------------------+------------------+------+
+ *
+ * - icon: Optional icon that will be shown in front of the URL.
+ * - edit actions start: Optional action icons injected by other components in front of the URL
+ * (e.g. search engines).
+ * - url: Editable URL of the currently displayed website.
+ * - edit actions end: Optional action icons injected by other components after the URL
+ * (e.g. barcode scanner).
+ * - exit: Button that switches back to display mode or invoke an app-defined callback.
+ */
+@Suppress("LargeClass")
+class EditToolbar internal constructor(
+ context: Context,
+ private val toolbar: BrowserToolbar,
+ internal val rootView: View,
+) {
+ private val logger = Logger("EditToolbar")
+
+ /**
+ * Data class holding the customizable colors in "edit mode".
+ *
+ * @property clear Color tint used for the "cancel" icon to leave "edit mode".
+ * @property icon Color tint of the icon displayed in front of the URL.
+ * @property hint Text color of the hint shown when the URL field is empty.
+ * @property text Text color of the URL.
+ * @property suggestionBackground The background color used for autocomplete suggestions.
+ * @property suggestionForeground The foreground color used for autocomplete suggestions.
+ * @property pageActionSeparator Color tint of separator dividing page actions.
+ */
+ data class Colors(
+ @ColorInt val clear: Int,
+ @ColorInt val erase: Int,
+ @ColorInt val icon: Int?,
+ @ColorInt val hint: Int,
+ @ColorInt val text: Int,
+ @ColorInt val suggestionBackground: Int,
+ @ColorInt val suggestionForeground: Int?,
+ @ColorInt val pageActionSeparator: Int,
+ )
+
+ private val autocompleteDispatcher = SupervisorJob() +
+ Executors.newFixedThreadPool(
+ AUTOCOMPLETE_QUERY_THREADS,
+ NamedThreadFactory("EditToolbar"),
+ ).asCoroutineDispatcher() +
+ CoroutineExceptionHandler { _, throwable ->
+ logger.error("Error while processing autocomplete input", throwable)
+ }
+
+ @VisibleForTesting(otherwise = PRIVATE)
+ internal val views = EditToolbarViews(
+ background = rootView.findViewById(R.id.mozac_browser_toolbar_background),
+ icon = rootView.findViewById(R.id.mozac_browser_toolbar_edit_icon),
+ editActionsStart = rootView.findViewById(R.id.mozac_browser_toolbar_edit_actions_start),
+ editActionsEnd = rootView.findViewById(R.id.mozac_browser_toolbar_edit_actions_end),
+ clear = rootView.findViewById(R.id.mozac_browser_toolbar_clear_view).apply {
+ setOnClickListener {
+ onClear()
+ }
+ },
+ erase = rootView.findViewById(R.id.mozac_browser_toolbar_erase_view).apply {
+ setOnClickListener {
+ onClear()
+ }
+ },
+ url = rootView.findViewById(
+ R.id.mozac_browser_toolbar_edit_url_view,
+ ).apply {
+ setOnCommitListener {
+ // We emit the fact before notifying the listener because otherwise the listener may cause a focus
+ // change which may reset the autocomplete state that we want to report here.
+ emitCommitFact(autocompleteResult)
+
+ toolbar.onUrlEntered(text.toString())
+ }
+
+ setOnTextChangeListener { text, _ ->
+ onTextChanged(text)
+ }
+
+ setUrlGoneMargin(
+ ConstraintSet.END,
+ context.resources.getDimensionPixelSize(R.dimen.mozac_browser_toolbar_url_gone_margin_end),
+ )
+
+ setOnDispatchKeyEventPreImeListener { event ->
+ if (event?.keyCode == KeyEvent.KEYCODE_BACK && editListener?.onCancelEditing() != false) {
+ toolbar.displayMode()
+ }
+ false
+ }
+ },
+ pageActionSeparator = rootView.findViewById(R.id.mozac_browser_action_separator),
+ )
+
+ /**
+ * Customizable colors in "edit mode".
+ */
+ var colors: Colors = Colors(
+ clear = ContextCompat.getColor(context, colorsR.color.photonWhite),
+ erase = ContextCompat.getColor(context, colorsR.color.photonWhite),
+ icon = null,
+ hint = views.url.currentHintTextColor,
+ text = views.url.currentTextColor,
+ suggestionBackground = views.url.autoCompleteBackgroundColor,
+ suggestionForeground = views.url.autoCompleteForegroundColor,
+ pageActionSeparator = ContextCompat.getColor(context, colorsR.color.photonGrey80),
+ )
+ set(value) {
+ field = value
+
+ views.clear.setColorFilter(value.clear)
+
+ views.erase.setColorFilter(value.erase)
+
+ if (value.icon != null) {
+ views.icon.setColorFilter(value.icon)
+ }
+
+ views.url.setHintTextColor(value.hint)
+ views.url.setTextColor(value.text)
+ views.url.autoCompleteBackgroundColor = value.suggestionBackground
+ views.url.autoCompleteForegroundColor = value.suggestionForeground
+ views.pageActionSeparator.setBackgroundColor(value.pageActionSeparator)
+ }
+
+ /**
+ * Sets the background that will be drawn behind the URL, icon and edit actions.
+ */
+ fun setUrlBackground(background: Drawable?) {
+ views.background.setImageDrawable(background)
+ }
+
+ /**
+ * Sets an icon that will be drawn in front of the URL.
+ */
+ fun setIcon(icon: Drawable, contentDescription: String) {
+ views.icon.setImageDrawable(icon)
+ views.icon.contentDescription = contentDescription
+ views.icon.visibility = View.VISIBLE
+ }
+
+ /**
+ * Sets a click listener on the icon view
+ */
+ fun setIconClickListener(listener: ((View) -> Unit)?) {
+ views.icon.setOnClickListener(listener)
+ }
+
+ /**
+ * Sets the text to be displayed when the URL of the toolbar is empty.
+ */
+ var hint: String
+ get() = views.url.hint.toString()
+ set(value) { views.url.hint = value }
+
+ /**
+ * Sets the size of the text for the URL/search term displayed in the toolbar.
+ */
+ var textSize: Float
+ get() = views.url.textSize
+ set(value) {
+ views.url.textSize = value
+ }
+
+ /**
+ * Sets the typeface of the text for the URL/search term displayed in the toolbar.
+ */
+ var typeface: Typeface
+ get() = views.url.typeface
+ set(value) { views.url.typeface = value }
+
+ /**
+ * Sets a listener to be invoked when focus of the URL input view (in edit mode) changed.
+ */
+ fun setOnEditFocusChangeListener(listener: (Boolean) -> Unit) {
+ views.url.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
+ listener.invoke(hasFocus)
+ }
+ }
+
+ /**
+ * Focuses the url input field and shows the virtual keyboard if needed.
+ */
+ fun focus() {
+ views.url.run {
+ if (!hasFocus()) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ // On Android 14 this needs to be called before requestFocus() in order to receive focus.
+ isFocusableInTouchMode = true
+ }
+ requestFocus()
+ showKeyboard()
+ }
+ }
+ }
+
+ internal fun stopEditing() {
+ editListener?.onStopEditing()
+ }
+
+ internal fun startEditing() {
+ editListener?.onStartEditing()
+ }
+
+ internal var editListener: Toolbar.OnEditListener? = null
+
+ internal fun setAutocompleteListener(filter: suspend (String, AutocompleteDelegate) -> Unit) {
+ views.url.setOnFilterListener(
+ AsyncFilterListener(views.url, autocompleteDispatcher, filter),
+ )
+ }
+
+ /**
+ * Attempt to restart the autocomplete functionality with the current user input.
+ */
+ internal fun refreshAutocompleteSuggestion() {
+ views.url.refreshAutocompleteSuggestions()
+ }
+
+ internal fun invalidateActions() {
+ views.editActionsStart.invalidateActions()
+ views.editActionsEnd.invalidateActions()
+ }
+
+ internal fun addEditActionStart(action: Toolbar.Action) {
+ views.editActionsStart.addAction(action)
+ }
+
+ internal fun addEditActionEnd(action: Toolbar.Action) {
+ views.editActionsEnd.addAction(action)
+ }
+
+ internal fun removeEditActionEnd(action: Toolbar.Action) {
+ views.editActionsEnd.removeAction(action)
+ }
+
+ /**
+ * Updates the text of the URL input field. Note: this does *not* affect the value of url itself
+ * and is only a visual change
+ */
+ fun updateUrl(
+ url: String,
+ shouldAutoComplete: Boolean = false,
+ shouldHighlight: Boolean = false,
+ shouldAppend: Boolean = false,
+ ): String {
+ if (shouldAppend) {
+ views.url.appendText(url, shouldAutoComplete)
+ } else {
+ views.url.setText(url, shouldAutoComplete)
+ }
+ views.clear.isVisible = url.isNotBlank() && !toolbar.isNavBarEnabled
+ views.erase.isVisible = url.isNotBlank() && toolbar.isNavBarEnabled
+
+ if (shouldHighlight) {
+ views.url.setSelection(views.url.text.length - url.length, views.url.text.length)
+ }
+ return views.url.text.toString()
+ }
+
+ /**
+ * Select the entire text in the URL input field.
+ */
+ internal fun selectAll() {
+ views.url.selectAll()
+ }
+
+ /**
+ * Places the cursor at the end of the URL input field.
+ */
+ internal fun selectEnd() {
+ views.url.setSelection(views.url.text.length)
+ }
+
+ /**
+ * Applies the given search terms for further editing, requesting new suggestions along the way.
+ */
+ internal fun editSuggestion(searchTerms: String) {
+ updateUrl(searchTerms)
+ views.url.setSelection(views.url.text.length)
+ focus()
+
+ editListener?.onTextChanged(searchTerms)
+ }
+
+ /**
+ * Sets/gets private mode.
+ *
+ * In private mode the IME should not update any personalized data such as typing history and personalized language
+ * model based on what the user typed.
+ */
+ internal var private: Boolean
+ get() = (views.url.imeOptions and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING) != 0
+ set(value) {
+ views.url.imeOptions = if (value) {
+ views.url.imeOptions or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING
+ } else {
+ views.url.imeOptions and (EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv())
+ }
+ }
+
+ private fun onClear() {
+ // We set text to an empty string instead of using clear to avoid #3612.
+ views.url.setText("")
+ editListener?.onInputCleared()
+ }
+
+ private fun setUrlGoneMargin(anchor: Int, dimen: Int) {
+ val set = ConstraintSet()
+ val container = rootView.findViewById(
+ R.id.mozac_browser_toolbar_container,
+ )
+ set.clone(container)
+ set.setGoneMargin(R.id.mozac_browser_toolbar_edit_url_view, anchor, dimen)
+ set.applyTo(container)
+ }
+
+ private fun onTextChanged(text: String) {
+ views.clear.isVisible = text.isNotBlank() && !toolbar.isNavBarEnabled
+ views.erase.isVisible = text.isNotBlank() && toolbar.isNavBarEnabled
+ views.editActionsEnd.autoHideAction(text.isEmpty())
+
+ /*
+ We use margin_gone instead of margin to take into account both the actionContainer(which in
+ most cases is gone) and the clear button.
+ */
+ if (text.isNotBlank()) {
+ setUrlGoneMargin(ConstraintSet.END, 0)
+ } else {
+ setUrlGoneMargin(
+ ConstraintSet.END,
+ rootView.resources.getDimensionPixelSize(
+ R.dimen.mozac_browser_toolbar_url_gone_margin_end,
+ ),
+ )
+ }
+ editListener?.onTextChanged(text)
+ }
+
+ /**
+ * Hides the page action separator in edit mode.
+ */
+ fun hidePageActionSeparator() {
+ views.pageActionSeparator.isVisible = false
+ }
+
+ /**
+ * Shows the page action separator in edit mode.
+ */
+ fun showPageActionSeparator() {
+ views.pageActionSeparator.isVisible = true
+ }
+}
+
+/**
+ * Internal holder for view references.
+ */
+@Suppress("LongParameterList")
+internal class EditToolbarViews(
+ val background: ImageView,
+ val icon: ImageView,
+ val editActionsStart: ActionContainer,
+ val editActionsEnd: ActionContainer,
+ val clear: ImageView,
+ val erase: ImageView,
+ val url: InlineAutocompleteEditText,
+ val pageActionSeparator: View,
+)
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/facts/ToolbarFacts.kt b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/facts/ToolbarFacts.kt
new file mode 100644
index 0000000000..06f09ea18d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/facts/ToolbarFacts.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.browser.toolbar2.facts
+
+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
+import mozilla.components.ui.autocomplete.InlineAutocompleteEditText
+
+/**
+ * Facts emitted for telemetry related to [ToolbarFeature]
+ */
+class ToolbarFacts {
+ /**
+ * Items that specify which portion of the [ToolbarFeature] was interacted with
+ */
+ object Items {
+ const val TOOLBAR = "toolbar"
+ const val MENU = "menu"
+ }
+}
+
+private fun emitToolbarFact(
+ action: Action,
+ item: String,
+ value: String? = null,
+ metadata: Map? = null,
+) {
+ Fact(
+ Component.BROWSER_TOOLBAR,
+ action,
+ item,
+ value,
+ metadata,
+ ).collect()
+}
+
+internal fun emitOpenMenuFact(extras: Map?) {
+ emitToolbarFact(Action.CLICK, ToolbarFacts.Items.MENU, metadata = extras)
+}
+
+internal fun emitCommitFact(
+ autocompleteResult: InlineAutocompleteEditText.AutocompleteResult?,
+) {
+ val metadata = if (autocompleteResult == null) {
+ mapOf(
+ "autocomplete" to false,
+ )
+ } else {
+ mapOf(
+ "autocomplete" to true,
+ "source" to autocompleteResult.source,
+ )
+ }
+
+ emitToolbarFact(Action.COMMIT, ToolbarFacts.Items.TOOLBAR, metadata = metadata)
+}
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/internal/ActionContainer.kt b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/internal/ActionContainer.kt
new file mode 100644
index 0000000000..7907dba520
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/internal/ActionContainer.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.browser.toolbar2.internal
+
+import android.content.Context
+import android.transition.TransitionManager
+import android.util.AttributeSet
+import android.view.Gravity
+import android.view.View
+import android.widget.LinearLayout
+import androidx.annotation.VisibleForTesting
+import androidx.core.view.isVisible
+import mozilla.components.browser.toolbar2.R
+import mozilla.components.concept.toolbar.Toolbar
+
+/**
+ * A container [View] for displaying [Toolbar.Action] objects.
+ */
+internal class ActionContainer @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+) : LinearLayout(context, attrs, defStyleAttr) {
+ private val actions = mutableListOf()
+ private var actionSize: Int? = null
+
+ init {
+ gravity = Gravity.CENTER_VERTICAL
+ orientation = HORIZONTAL
+ visibility = View.GONE
+
+ context.obtainStyledAttributes(
+ attrs,
+ R.styleable.ActionContainer,
+ defStyleAttr,
+ 0,
+ ).run {
+ actionSize = attrs?.let {
+ getDimensionPixelSize(R.styleable.ActionContainer_actionContainerItemSize, 0)
+ }
+
+ recycle()
+ }
+ }
+
+ fun addAction(action: Toolbar.Action) {
+ val wrapper = ActionWrapper(action)
+
+ if (action.visible()) {
+ visibility = View.VISIBLE
+
+ action.createView(this).let {
+ wrapper.view = it
+ val insertionIndex = calculateInsertionIndex(action)
+ addActionView(it, insertionIndex)
+ }
+ }
+
+ actions.add(wrapper)
+ }
+
+ /**
+ * Essentially calculates the index of an action on toolbar based on a
+ * map [visibleActionIndicesWithWeights] that holds the order
+ * of visible action indices to their weights sorted by weights.
+ * An index is now calculated by finding the immediate next larger weight
+ * compared to the new action's weight. Index of this find becomes the index of the new action.
+ * If not found, action is appended at the end.
+ */
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal fun calculateInsertionIndex(newAction: Toolbar.Action): Int {
+ if (newAction.weight() == -1) {
+ return -1
+ }
+ val visibleActionIndicesWithWeights = actions.filter { it.actual.visible() }
+ .mapNotNull { actionWrapper ->
+ val index = indexOfChild(actionWrapper.view)
+ if (index != -1) index to actionWrapper.actual.weight() else null
+ }.sortedBy { it.second }
+
+ val insertionIndex = visibleActionIndicesWithWeights.firstOrNull { it.second > newAction.weight() }?.first
+
+ return insertionIndex ?: childCount
+ }
+
+ fun removeAction(action: Toolbar.Action) {
+ actions.find { it.actual == action }?.let {
+ actions.remove(it)
+ removeView(it.view)
+ }
+ }
+
+ fun invalidateActions() {
+ TransitionManager.beginDelayedTransition(this)
+ var updatedVisibility = View.GONE
+
+ for (action in actions) {
+ val visible = action.actual.visible()
+
+ if (visible) {
+ updatedVisibility = View.VISIBLE
+ }
+
+ if (!visible && action.view != null) {
+ removeView(action.view)
+ action.view = null
+ } else if (visible && action.view == null) {
+ action.actual.createView(this).let {
+ action.view = it
+ val insertionIndex = calculateInsertionIndex(action.actual)
+ addActionView(it, insertionIndex)
+ }
+ }
+
+ action.view?.let { action.actual.bind(it) }
+ }
+
+ visibility = updatedVisibility
+ }
+
+ fun autoHideAction(isVisible: Boolean) {
+ for (action in actions) {
+ if (action.actual.autoHide()) {
+ action.view?.isVisible = isVisible
+ }
+ }
+ }
+
+ private fun addActionView(view: View, index: Int) {
+ addView(view, index, LayoutParams(actionSize ?: 0, actionSize ?: 0))
+ }
+}
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/internal/ActionWrapper.kt b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/internal/ActionWrapper.kt
new file mode 100644
index 0000000000..8600372f1e
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/java/mozilla/components/browser/toolbar2/internal/ActionWrapper.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.browser.toolbar2.internal
+
+import android.view.View
+import mozilla.components.concept.toolbar.Toolbar
+
+/**
+ * A wrapper helper to pair a Toolbar.Action with an optional View.
+ */
+internal class ActionWrapper(
+ var actual: Toolbar.Action,
+ var view: View? = null,
+)
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_browser_toolbar_icons_vertical_separator.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_browser_toolbar_icons_vertical_separator.xml
new file mode 100644
index 0000000000..2e366640df
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_browser_toolbar_icons_vertical_separator.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_dot_notification.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_dot_notification.xml
new file mode 100644
index 0000000000..5469b822af
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_dot_notification.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_ic_site_security.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_ic_site_security.xml
new file mode 100644
index 0000000000..ab2b8d0e37
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_ic_site_security.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_ic_tracking_protection_off_for_a_site.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_ic_tracking_protection_off_for_a_site.xml
new file mode 100644
index 0000000000..250bfbfc4c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_ic_tracking_protection_off_for_a_site.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_ic_tracking_protection_on_no_trackers_blocked.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_ic_tracking_protection_on_no_trackers_blocked.xml
new file mode 100644
index 0000000000..4f81a5602b
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_ic_tracking_protection_on_no_trackers_blocked.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_ic_tracking_protection_on_trackers_blocked.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_ic_tracking_protection_on_trackers_blocked.xml
new file mode 100644
index 0000000000..4f81a5602b
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/drawable/mozac_ic_tracking_protection_on_trackers_blocked.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/layout/mozac_browser_toolbar_displaytoolbar.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/layout/mozac_browser_toolbar_displaytoolbar.xml
new file mode 100644
index 0000000000..5a5f779a70
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/layout/mozac_browser_toolbar_displaytoolbar.xml
@@ -0,0 +1,175 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/layout/mozac_browser_toolbar_edittoolbar.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/layout/mozac_browser_toolbar_edittoolbar.xml
new file mode 100644
index 0000000000..3325cd099f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/layout/mozac_browser_toolbar_edittoolbar.xml
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-am/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-am/strings.xml
new file mode 100644
index 0000000000..eda99f879b
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-am/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ ምናሌ
+ አጽዳ
+
+ የመከታተያ ጥበቃ በርቷል
+
+ የክትትል ጥበቃ መከታተያዎችን አግዷል
+
+ የክትትል ጥበቃ ለዚህ ድረ-ገፅ ጠፍቷል
+
+ የድረ-ገፅ መረጃ
+
+ በመጫን ላይ
+
+ አንዳንድ ይዘቶች በራስ አጫውት ቅንብር ታግደዋል
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-an/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-an/strings.xml
new file mode 100644
index 0000000000..14b40de38e
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-an/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+ Menú
+ Borrar
+
+ La protección contra seguimiento ye activada
+
+ Las protección contra seguimiento ha blocau elementos de seguimiento
+
+ La protección de seguimiento ye desactivada en este puesto
+
+ Información d’o puesto
+
+ Se ye cargando
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ar/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ar/strings.xml
new file mode 100644
index 0000000000..a4a01aa836
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ar/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ القائمة
+ امسح
+
+ الحماية من التعقّب مفعّلة
+
+ حجبت الحماية من التعقّب بعض المتعقّبات
+
+ عُطّلت الحماية من التعقب في هذا الموقع
+
+ معلومات الموقع
+
+ يُحمّل
+
+ حجب إعداد التشغيل التلقائي بعض المحتوى
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ast/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ast/strings.xml
new file mode 100644
index 0000000000..6cb4e96a9a
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ast/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Menú
+ Borrar
+
+ La proteición antirrastrexu ta activada
+
+ La proteición antirrastrexu bloquió rastrexadores
+
+ La proteición antirrastrexu ta desactivada nesti sitiu
+
+ Información del sitiu
+
+ Cargando
+
+ La configuración de la reproducción automática bloquió parte del conteníu
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-az/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-az/strings.xml
new file mode 100644
index 0000000000..0ccb692a99
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-az/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+ Menyu
+ Təmizlə
+
+ İzlənmə Qoruması açıqdır
+
+ İzlənmə Qoruması izləyiciləri əngəllədi
+
+ İzlənmə Qoruması bu sayt üçün sönülüdür
+
+ Sayt məlumatları
+
+ Yüklənir
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-azb/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-azb/strings.xml
new file mode 100644
index 0000000000..d2901fff35
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-azb/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ منو
+
+ پوز
+
+ ایزلهمه قورونماسی آچیقدیر
+
+ تعقیب قوروماسی تعقیبچیلری مسدود ائدیپدیر
+
+ بو سایت اوچون ایزلهمه قورونماسی باغلیدیر.
+
+ سایت بیلگیلری
+
+ دولور
+
+ بعضی محتوا اتوْماتیکچال تنظیمی ایله بلوْکلانیب
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ban/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ban/strings.xml
new file mode 100644
index 0000000000..037114093f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ban/strings.xml
@@ -0,0 +1,8 @@
+
+
+
+ Menu
+ Puyung
+
+ Jantosang dumun
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-be/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-be/strings.xml
new file mode 100644
index 0000000000..5042e0d03d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-be/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Меню
+
+ Ачысціць
+
+ Ахова ад сачэння ўключана
+
+ Ахова ад сачэння заблакавала трэкеры
+
+ Ахова ад сачэння выключана на гэтым сайце
+
+ Інфармацыя пра сайт
+
+ Загрузка
+
+ Некаторае змесціва было заблакавана наладамі аўтапрайгравання
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-bg/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-bg/strings.xml
new file mode 100644
index 0000000000..6f6d6ecbe0
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-bg/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Меню
+
+ Изчистване
+
+ Защита от проследяване включена
+
+ Защитата от проследяване е спряла проследяване
+
+ Защитата от проследяване е изключена за сайта
+
+ Показване на информация за сайта
+
+ Зареждане
+
+ Част от съдържанието е спряно от настройките за автоматично възпроизвеждане
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-bn/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-bn/strings.xml
new file mode 100644
index 0000000000..8c93a8c6e7
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-bn/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ মেনু
+ পরিষ্কার করুন
+
+ ট্র্যাকিং সুরক্ষা চালু আছে
+
+ ট্র্যাকিং সুরক্ষা ট্র্যাকারদের অবরুদ্ধ করেছে
+
+ এই সাইটের জন্য ট্র্যাকিং সুরক্ষা বন্ধ
+
+ সাইটের তথ্য
+
+ লোড হচ্ছে
+
+ কিছু বিষয়বস্তু অটোপ্লে সেটিং দ্বারা ব্লক করা হয়েছে
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-br/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-br/strings.xml
new file mode 100644
index 0000000000..c5f9c203fd
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-br/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Lañser
+
+ Skarzhañ
+
+ Gweredekaet eo ar gware heuliañ
+
+ Stanket ez eus bet heulierien gant ar gwarez heuliañ
+
+ Diweredekaet eo bet ar gwarez heuliañ evit al lecʼhienn-mañ
+
+ Titouroù al lecʼhienn
+
+ O kargañ
+
+ Elfennoù ’zo a zo bet stanket gant arventenn al lenn emgefreek
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-bs/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-bs/strings.xml
new file mode 100644
index 0000000000..473bf58af2
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-bs/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Meni
+
+ Očisti
+
+ Zaštita od praćenja uključena
+
+ Zaštita od praćenja je blokirala pratioce
+
+ Zaštita od praćenja je isključena za ovu stranicu
+
+ Informacije o stranici
+
+ Učitavanje
+
+ Neki sadržaj je blokiran postavkom automatske reprodukcije
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ca/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ca/strings.xml
new file mode 100644
index 0000000000..44f350dcf2
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ca/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Menú
+ Esborra
+
+ La protecció contra el seguiment està activada
+
+ La protecció contra el seguiment ha blocat elements de seguiment coneguts
+
+ S’ha desactivat la protecció contra el seguiment per a aquest lloc
+
+ Informació del lloc
+
+ S’està carregant
+
+ Una part del contingut s’ha blocat per la configuració de la reproducció automàtica
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-cak/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-cak/strings.xml
new file mode 100644
index 0000000000..ae15567dc0
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-cak/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ K\'utsamaj
+
+ Tijosq\'ïx
+
+ Tzijïl ri Chajinïk Chuwäch Ojqanïk
+
+ Eruq\'aton ojqanela\' ri i Chajinïk chuwäch Ojqanïk
+
+ Chupül ri Chajinïk chuwäch Ojqanem pa re ruxaq re\'
+
+ Rutzijol ruxaq
+
+ Nusamajij
+
+ Jun peraj chi re ri rupam xq\'at ruma ri runuk\'ulem ri ruyon nitzij
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ceb/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ceb/strings.xml
new file mode 100644
index 0000000000..4a03205a32
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ceb/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Menu
+ Panas
+
+ Ang Tracking Protection on
+
+ Ang Tracking Protection nibara ug tracker
+
+ Wala\'y Tracking Protection ani nga site
+
+ Detalye sa site
+
+ Loading
+
+ Ang uban content gibara sa setting sa autoplay
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ckb/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ckb/strings.xml
new file mode 100644
index 0000000000..95e99bea91
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ckb/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ پێڕست
+ پاککردنەوە
+
+ پارێزگاری لە چاودێری کارایە
+
+ پارێزگاری چاودێری توانی چەند چاودێریکەرێک بلۆک بکات
+
+ پارێزگاری لە چاودێری ناکارایە بۆ ئەم ماڵپەڕە
+
+ زانیاری ماڵپەڕ
+
+ باردەکرێت
+
+ هەندێک ناوەڕۆک بلۆک کران لە لایەن ڕێکخستنی خۆپێکردنەوە
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-co/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-co/strings.xml
new file mode 100644
index 0000000000..2a309bb0b3
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-co/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Listinu
+
+ Squassà
+
+ A prutezzione contr’à u spiunagiu hè attivata
+
+ A prutezzione contr’à u spiunagiu hà bluccatu perseguitatori
+
+ A prutezzione contr’à u spiunagiu hè disattivata per stu situ
+
+ Infurmazioni nant’à u situ
+
+ Caricamentu in corsu
+
+ Certi cuntenuti sò stati bluccati da a preferenza di lettura autumatica
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-cs/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-cs/strings.xml
new file mode 100644
index 0000000000..a2c3e0d17a
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-cs/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Nabídka
+
+ Vymazat
+
+ Ochrana proti sledování je zapnuta
+
+ Ochrana proti sledování zablokovala sledovací prvky
+
+ Ochrana proti sledování je pro tento web vypnuta
+
+ Informace o stránce
+
+ Načítání
+
+ Část obsahu byla zablokována nastavením automatického přehrávání
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-cy/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-cy/strings.xml
new file mode 100644
index 0000000000..3689bb4004
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-cy/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Dewislen
+
+ Clirio
+
+ Mae Diogelwch rhag Tracio ymlaen
+
+ Mae Diogelwch rhag Tracio wedi rhwystro tracwyr
+
+ Mae Diogelwch rhag Tracio wedi ei ddiffodd ar gyfer y wefan hon
+
+ Manylion y wefan
+
+ Llwytho
+
+ Mae rhywfaint o gynnwys wedi’i rwystro gan osodiadau awtochwarae
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-da/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-da/strings.xml
new file mode 100644
index 0000000000..b8d4ba146f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-da/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menu
+
+ Ryd
+
+ Beskyttelse mod sporing er slået til
+
+ Beskyttelse mod sporing har blokeret sporings-tjenester
+
+ Beskyttelse mod sporing er slået fra for dette websted
+
+ Information om webstedet
+
+ Indlæser
+
+ Noget indhold er blevet blokeret af indstillingen for automatisk afspilning
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-de/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-de/strings.xml
new file mode 100644
index 0000000000..2db37ddfd5
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-de/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menü
+
+ Leeren
+
+ Schutz vor Aktivitätenverfolgung ist an
+
+ Der Tracking-Schutz hat Tracker blockiert
+
+ Der Tracking-Schutz ist für diese Website deaktiviert
+
+ Seiteninformation
+
+ Wird geladen…
+
+ Einige Inhalte wurden durch die Einstellung zur automatischen Wiedergabe blockiert
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-dsb/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-dsb/strings.xml
new file mode 100644
index 0000000000..5f13b6571d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-dsb/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Meni
+
+ Lašowaś
+
+ Slědowański šćit jo zašaltowany
+
+ Slědowański šćit jo blokěrował pśeslědowaki
+
+ Slědowański šćit jo něnto znjemóžnjony za toś to sedło
+
+ Sedłowe informacije
+
+ Zacytujo se
+
+ Někake wopśimjeśe jo se pśez wótgrawańske nastajenje zablokěrował
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-el/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-el/strings.xml
new file mode 100644
index 0000000000..8960320249
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-el/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Μενού
+
+ Απαλοιφή
+
+ Η προστασία από καταγραφή είναι ενεργή
+
+ Η προστασία από καταγραφή έχει αποκλείσει ιχνηλάτες
+
+ Η προστασία από καταγραφή είναι ανενεργή για τον ιστότοπο
+
+ Πληροφορίες ιστοτόπου
+
+ Φόρτωση
+
+ Ορισμένο περιεχόμενο έχει αποκλειστεί από τη ρύθμιση αυτόματης αναπαραγωγής
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-en-rCA/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-en-rCA/strings.xml
new file mode 100644
index 0000000000..29866e8096
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-en-rCA/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menu
+
+ Clear
+
+ Tracking Protection is on
+
+ Tracking Protection has blocked trackers
+
+ Tracking Protection is off for this site
+
+ Site information
+
+ Loading
+
+ Some content has been blocked by the autoplay setting
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-en-rGB/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-en-rGB/strings.xml
new file mode 100644
index 0000000000..29866e8096
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-en-rGB/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menu
+
+ Clear
+
+ Tracking Protection is on
+
+ Tracking Protection has blocked trackers
+
+ Tracking Protection is off for this site
+
+ Site information
+
+ Loading
+
+ Some content has been blocked by the autoplay setting
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-eo/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-eo/strings.xml
new file mode 100644
index 0000000000..5f31034648
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-eo/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menuo
+
+ Viŝi
+
+ Protekto kontraŭ spurado ŝaltita
+
+ La protekto kontraŭ spurado blokis spurilojn
+
+ Protekto kontraŭ spurado malŝaltita por tiu ĉi retejo
+
+ Informo pri retejo
+
+ Ŝargado
+
+ Parto de la enhavo estis blokita de la agordo pri aŭtomata ludado
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-es-rAR/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-es-rAR/strings.xml
new file mode 100644
index 0000000000..d5fa1f30f0
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-es-rAR/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menú
+
+ Limpiar
+
+ La protección contra rastreo está habilitada
+
+ La protección contra rastreo bloqueó los rastreadores
+
+ La protección de rastreo está deshabilitada para este sitio
+
+ Información del sitio
+
+ Cargando
+
+ Se bloquearon algunos contenidos debido a la configuración de reproducción automática
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-es-rCL/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-es-rCL/strings.xml
new file mode 100644
index 0000000000..107843b536
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-es-rCL/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menú
+
+ Limpiar
+
+ Protección de seguimiento activada
+
+ La Protección de seguimiento ha bloqueado rastreadores
+
+ Protección de seguimiento desactivada para este sitio
+
+ Información del sitio
+
+ Cargando
+
+ Algunos contenidos han sido bloqueados por los ajustes de reproducción automática
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-es-rES/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-es-rES/strings.xml
new file mode 100644
index 0000000000..9c4263b840
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-es-rES/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menú
+
+ Limpiar
+
+ La protección contra rastreo está activada
+
+ La protección contra rastreo ha bloqueado rastreadores
+
+ La protección contra rastreo está desactivada para este sitio web
+
+ Información del sitio
+
+ Cargando
+
+ Algunos contenidos han sido bloqueados por los ajustes de reproducción automática
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-es-rMX/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-es-rMX/strings.xml
new file mode 100644
index 0000000000..cd4388769d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-es-rMX/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Menú
+ Limpiar
+
+ La protección contra rastreo está activada
+
+ La protección contra rastreo ha bloqueado rastreadores
+
+ La protección contra rastreo está desactivada para este sitio
+
+ Información del sitio
+
+ Cargando
+
+ Parte del contenido ha sido bloqueado por la configuración de reproducción automática
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-es/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-es/strings.xml
new file mode 100644
index 0000000000..9c4263b840
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-es/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menú
+
+ Limpiar
+
+ La protección contra rastreo está activada
+
+ La protección contra rastreo ha bloqueado rastreadores
+
+ La protección contra rastreo está desactivada para este sitio web
+
+ Información del sitio
+
+ Cargando
+
+ Algunos contenidos han sido bloqueados por los ajustes de reproducción automática
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-et/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-et/strings.xml
new file mode 100644
index 0000000000..7fb1096fd7
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-et/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Menüü
+ Tühjenda
+
+ Jälitamisvastane kaitse on sees
+
+ Jälitamisvastane kaitse on jälitajaid blokkinud
+
+ Täiustatud jälitamisvastane kaitse on sellel saidil väljas
+
+ Saidi teave
+
+ Laadimine
+
+ Osa sisu on automaatse esitamise sättega blokitud
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-eu/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-eu/strings.xml
new file mode 100644
index 0000000000..fb0520f183
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-eu/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menua
+
+ Garbitu
+
+ Jarraipenaren babesa gaituta dago
+
+ Jarraipenaren babesak jarraipen-elementuak blokeatu ditu
+
+ Jarraipenaren babesa desgaituta dago webgune honetarako
+
+ Gunearen informazioa
+
+ Kargatzen
+
+ Eduki batzuk blokeatu egin dira erreprodukzio automatikoko ezarpenetan oinarrituta
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-fa/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-fa/strings.xml
new file mode 100644
index 0000000000..f381a2069f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-fa/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ منو
+ پاک کردن
+
+ حفاظت در برابر ردیابی روشن است
+
+ حفاظت در برابر ردیابی، ردیابها را مسدود کرده است
+
+ حفاظت در برابر ردیابی برای این پایگاه خاموش است
+
+ اطلاعات پایگاه
+
+ در حال بار کردن
+
+ برخی از محتواها توسط تنظیمات پخش خودکار مسدود شدهاند
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ff/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ff/strings.xml
new file mode 100644
index 0000000000..b774316dfb
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ff/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+ Dosol
+ Momtu
+
+ Ndeenka Dewindol nani e
+
+ Ndeenka Dewindol faliima rewindotooɓe
+
+ Ndeenka Dewindol ko ko ñifi e ndee lowre
+
+ Humpito lowre
+
+ Nana loowa
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-fi/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-fi/strings.xml
new file mode 100644
index 0000000000..b47d8af7bc
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-fi/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Valikko
+
+ Tyhjennä
+
+ Seurannan suojaus on päällä
+
+ Seurannan suojaus on estänyt seuraimia
+
+ Seurannan suojaus ei ole käytössä tällä sivustolla
+
+ Sivustotiedot
+
+ Ladataan
+
+ Automaattisen toiston asetus on estänyt osan sisällöstä
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-fr/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-fr/strings.xml
new file mode 100644
index 0000000000..072941bc03
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-fr/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menu
+
+ Effacer
+
+ La protection contre le pistage est activée
+
+ La protection contre le pistage a bloqué des traqueurs
+
+ La protection contre le pistage est désactivée pour ce site
+
+ Informations sur le site
+
+ Chargement en cours
+
+ Certains contenus ont été bloqués par le paramètre de lecture automatique
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-fur/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-fur/strings.xml
new file mode 100644
index 0000000000..19900e6bc0
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-fur/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menù
+
+ Nete
+
+ La protezion da lis spiis e je ative
+
+ La protezion da lis spiis e à blocât spiis
+
+ La protezion da lis spiis e je disativade par chest sît
+
+ Informazions sît
+
+ Daûr a cjamâ
+
+ Cualchi contignût al è stât blocât de impostazion pe riproduzion automatiche
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-fy-rNL/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-fy-rNL/strings.xml
new file mode 100644
index 0000000000..9aca189901
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-fy-rNL/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menu
+
+ Wiskje
+
+ Beskerming tsjin folgjen is ynskeakele
+
+ Beskerming tsjin folgjen hat trackers blokkearre
+
+ Beskerming tsjin folgjen is út foar dizze website
+
+ Website-ynformaasje
+
+ Lade
+
+ Guon ynhâld is blokkearre troch de ynstelling foar automatysk ôfspyljen
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ga-rIE/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ga-rIE/strings.xml
new file mode 100644
index 0000000000..ba4f41d62f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ga-rIE/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+ Roghchlár
+ Bánaigh
+
+ Tá Cosaint ar Lorgaireacht ar siúl
+
+ Chuir Cosaint ar Lorgaireacht cosc ar lorgairí
+
+ Tá Cosaint ar Lorgaireacht múchta don suíomh seo
+
+ Eolas faoin suíomh
+
+ Á lódáil
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-gd/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-gd/strings.xml
new file mode 100644
index 0000000000..d694698b3d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-gd/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Clàr-taice
+ Falamhaich
+
+ Tha an dìon o thracadh air
+
+ Bhac gleus an dìon o thracadh tracaichean
+
+ Tha an dìon o thracadh dheth air an làrach seo
+
+ Fiosrachadh mun làrach
+
+ Ga luchdadh
+
+ Chaidh cuid dhen t-susbaint a bhacadh an cois roghainn na fèin-chluich
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-gl/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-gl/strings.xml
new file mode 100644
index 0000000000..990d910d08
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-gl/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menú
+
+ Borrar
+
+ Protección contra o rastrexo activada
+
+ A protección contra o rastrexo bloqueou rastrexadores
+
+ A protección contra o rastrexo está desactivada para este sitio
+
+ Información sobre o sitio
+
+ Cargando
+
+ A configuración de reprodución automática bloqueou algúns contidos
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-gn/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-gn/strings.xml
new file mode 100644
index 0000000000..7798e540da
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-gn/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Poravorã
+
+ Mopotĩ
+
+ Tapykuehoha ñemo’ã oñemyandy
+
+ Ñemo’ã jehekaha ojoko tapykuehohápe
+
+ Ñemo’ã jehekaha ndoikovéima ko tendápe g̃uarã
+
+ Marandu tenda rehegua
+
+ Henyhẽhína
+
+ Ojejoko ndahetái tetepy ñemboheta ijeheguíva ñemboheko rupive
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-gu-rIN/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-gu-rIN/strings.xml
new file mode 100644
index 0000000000..a6ff2d26b0
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-gu-rIN/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+ મેનુ
+ સાફ કરો
+
+ ટ્રેકિંગ સુરક્ષા ચાલુ છે
+
+ ટ્રેકિંગ સુરક્ષા દ્વારા ટ્રેકર્સને અવરોધિત કરવામાં આવ્યા છે
+
+ આ સાઇટ માટે ટ્રેકિંગ સુરક્ષા બંધ છે
+
+ સાઇટ માહિતી
+
+ લોડ કરી રહ્યું છે
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hi-rIN/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hi-rIN/strings.xml
new file mode 100644
index 0000000000..2d3c7d9b42
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hi-rIN/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+ मेन्यू
+ साफ करें
+
+ ट्रैकिंग सुरक्षा चालू है
+
+ ट्रैकिंग सुरक्षा ने ट्रैकरों को अवरुद्ध कर दिया है
+
+ इस साइट के लिए ट्रैकिंग सुरक्षा बंद है
+
+ साइट सूचना
+
+ लोड हो रहा है
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hil/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hil/strings.xml
new file mode 100644
index 0000000000..b02bc03a94
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hil/strings.xml
@@ -0,0 +1,8 @@
+
+
+
+ Menu
+ Klaro
+
+ Loading
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hr/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hr/strings.xml
new file mode 100644
index 0000000000..420583468b
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hr/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Izbornik
+ Izbriši
+
+ Zaštita od praćenja je uključena
+
+ Zaštita od praćenja je blokirala programe za praćenje
+
+ Zaštita od praćenja je isključena za ovu stranicu
+
+ Informacije o web mjestu
+
+ Učitavanje
+
+ Neki sadržaji su blokirani zbog postavki automatske reprodukcije
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hsb/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hsb/strings.xml
new file mode 100644
index 0000000000..2c03305e16
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hsb/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Meni
+
+ Zhašeć
+
+ Slědowanski škit je zmóžnjeny
+
+ Slědowanski škit je přesćěhowaki blokował
+
+ Slědowanski škit je znjemóžnjeny za tute sydło
+
+ Sydłowe informacije
+
+ Začituje so
+
+ Někajki wobsah je so přez wothrawanske nastajenje zablokował
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hu/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hu/strings.xml
new file mode 100644
index 0000000000..eef3c81920
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hu/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menü
+
+ Törlés
+
+ Követés elleni védelem bekapcsolva
+
+ A követés elleni védelem nyomkövetőket blokkolt
+
+ A követés elleni védelem le van tiltva ezen az oldalon
+
+ Oldalinformációk
+
+ Betöltés
+
+ Bizonyos tartalmakat letiltott az automatikus lejátszási beállítás
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hy-rAM/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hy-rAM/strings.xml
new file mode 100644
index 0000000000..668ae3425c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-hy-rAM/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Ցանկ
+
+ Մաքրել
+
+ Հետագծման պաշտպանությունը միաց. է
+
+ Հետագծման պաշտպանությունն արգելափակել է հետագծիչները
+
+ Հետագծման պաշտպանությունն անջ. է այս կայքի համար
+
+ Կայքի տեղեկատվություն
+
+ Բեռնում
+
+ Որոշ բովանդակություն արգելափակվել է ինքնանվագարկման կարգավորումով
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ia/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ia/strings.xml
new file mode 100644
index 0000000000..8a27cf8a5a
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ia/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menu
+
+ Vacuar
+
+ Protection contra le traciamento active
+
+ Le protection contra le traciamento ha blocate traciatores
+
+ Le protection contra le traciamento es disactivate pro iste sito
+
+ Informationes del sito
+
+ Cargamento
+
+ Alcun contento ha essite blocate per le parametros del reproduction automatic
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-in/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-in/strings.xml
new file mode 100644
index 0000000000..2d7074c6ba
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-in/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Menu
+ Bersihkan
+
+ Perlindungan Pelacakan aktif
+
+ Perlindungan Pelacakan telah memblokir pelacak
+
+ Perlindungan Pelacakan dinonaktifkan untuk situs ini
+
+ Informasi situs
+
+ Memuat
+
+ Beberapa konten telah diblokir dengan pengaturan putar-otomatis
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-is/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-is/strings.xml
new file mode 100644
index 0000000000..653bc2c4d0
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-is/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Valmynd
+
+ Hreinsa
+
+ Vörn gegn gagnasöfnun virk
+
+ Vörn gegn gagnasöfnun hefur lokað á rekjara
+
+ Vörn gegn gagnasöfnun er ekki virk fyrir þetta svæði
+
+ Upplýsingar um vefsvæði
+
+ Hleður
+
+ Lokað hefur verið sumt efni með stillingum fyrir sjálfvirka afspilun
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-it/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-it/strings.xml
new file mode 100644
index 0000000000..33d9f28220
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-it/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menu
+
+ Cancella
+
+ La protezione antitracciamento è attiva
+
+ La protezione antitracciamento ha bloccato contenuti traccianti
+
+ La protezione antitracciamento è disattivata per questo sito
+
+ Informazioni sito
+
+ Caricamento…
+
+ Alcuni contenuti sono stati bloccati dall’impostazione per la riproduzione automatica
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-iw/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-iw/strings.xml
new file mode 100644
index 0000000000..51477ead53
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-iw/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ תפריט
+
+ ניקוי
+
+ הגנת מעקב פעילה
+
+ הגנת מעקב חסמה רכיבי מעקב
+
+ הגנת מעקב כבויה עבור אתר זה
+
+ פרטי האתר
+
+ בטעינה
+
+ תוכן מסויים נחסם על־ידי ההגדרה של הניגון האוטומטי
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ja/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ja/strings.xml
new file mode 100644
index 0000000000..4707063495
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ja/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ メニュー
+
+ 消去
+
+ トラッキング防止はオンです
+
+ トラッキング防止によりトラッカーをブロックしました
+
+ このサイトではトラッキング防止がオフです
+
+ サイト情報
+
+ 読み込み中
+
+ 一部のコンテンツは自動再生設定によってブロックされています
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ka/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ka/strings.xml
new file mode 100644
index 0000000000..bf438a8ed7
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ka/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ მენიუ
+ გასუფთავება
+
+ თვალთვალისგან დაცვა ჩართულია
+
+ თვალთვალისგან დაცვამ შეზღუდა მეთვალყურეები
+
+ თვალთვალისგან დაცვა გამორთულია ამ საიტზე
+
+ საიტის მონაცემები
+
+ იტვირთება
+
+ ზოგიერთი მასალა შეიზღუდა თვითგაშვების პარამეტრებით
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-kaa/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-kaa/strings.xml
new file mode 100644
index 0000000000..e4ad1073b0
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-kaa/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Menyu
+ Tazalaw
+
+ Baqlaw qorǵanıwı qosılǵan
+
+ Baqlaw qorǵanıw funkciyası trekkerlerdi blokladı
+
+ Bul sayt ushın baqlaw qorǵanıwı óshirilgen
+
+ Sayt maǵlıwmatları
+
+ Júklenbekte
+
+ Ayrım kontentler avtomatik sazlawlar tárepinen bloklanǵan
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-kab/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-kab/strings.xml
new file mode 100644
index 0000000000..034ec09580
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-kab/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Umuɣ
+
+ Sfeḍ
+
+ Ammesten mgal aḍfaṛ yermed
+
+ Ammesten mgal aḍfaṛ yessewḥel ineḍfaṛen
+
+ Ammesten mgal aḍfaṛ insa akka tura i usmel-a
+
+ Asmel n telɣut
+
+ Asali
+
+ Yettusewḥel kra n yigburen s aɣewwar n tɣuri tawurmant
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-kk/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-kk/strings.xml
new file mode 100644
index 0000000000..17b7710fac
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-kk/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Мәзір
+
+ Тазарту
+
+ Бақылаудан қорғаныс іске қосулы
+
+ Бақылаудан қорғаныс трекерлерді бұғаттады
+
+ Бақылаудан қорғаныс бұл сайт үшін сөндірілген
+
+ Сайт ақпараты
+
+ Жүктелуде
+
+ Кейбір құрама автоойнату баптауларымен бұғатталған
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-kmr/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-kmr/strings.xml
new file mode 100644
index 0000000000..29542924a0
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-kmr/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Menû
+ Paqij bike
+
+ Parastina ji Şopandinê vekirî ye
+
+ Parastina ji şopandinê şopdar asteng kirin
+
+ Parastina ji şopadinê, ji bo vê malperê girtî ye
+
+ Agahiyên malperê
+
+ Tê barkirin
+
+ Hin naverok ji aliyê eyara lêdana-otomatîk ve hatin astengkirin
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-kn/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-kn/strings.xml
new file mode 100644
index 0000000000..ec07a646a6
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-kn/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+ ಪರಿವಿಡಿ
+ ಅಳಿಸು
+
+ ಜಾಡು ಇರಿಸುವಿಕೆ ಇಂದ ರಕ್ಷಣೆ ಶುರುವಾಗಿದೆ
+
+ ಟ್ರ್ಯಾಕಿಂಗ್ ಪ್ರೊಟೆಕ್ಷನ್ ಟ್ರ್ಯಾಕರ್ಗಳನ್ನು ನಿರ್ಬಂಧಿಸಿದೆ
+
+ ಈ ಸೈಟ್ಗಾಗಿ ಟ್ರ್ಯಾಕಿಂಗ್ ಪ್ರೊಟೆಕ್ಷನ್ ಆಫ್ ಆಗಿದೆ
+
+ ತಾಣದ ಮಾಹಿತಿ
+
+ ಲೋಡ್ ಆಗುತ್ತಿದೆ
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ko/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ko/strings.xml
new file mode 100644
index 0000000000..ff3fcf3bcc
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ko/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ 메뉴
+
+ 지우기
+
+ 추적 방지 기능이 켜져 있음
+
+ 추적 방지 기능이 추적기를 차단함
+
+ 이 사이트에 추적 방지 기능이 꺼져 있음
+
+ 사이트 정보
+
+ 로드 중
+
+ 자동 재생 설정에 의해 일부 콘텐츠가 차단되었습니다.
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ldrtl/dimens.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ldrtl/dimens.xml
new file mode 100644
index 0000000000..a36c7c4366
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ldrtl/dimens.xml
@@ -0,0 +1,8 @@
+
+
+
+
+ 16dp
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-lij/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-lij/strings.xml
new file mode 100644
index 0000000000..31fd025526
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-lij/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+ Menû
+ Scancella
+
+ Proteçion anti-traciamento açeiza
+
+ A proteçion anti-traciamento a l\'à blocou di traciatoî
+
+ A proteçion anti-traciamento a l\'é asmortâ pe sto scito
+
+ Informaçioin do scito
+
+ Carego
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-lo/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-lo/strings.xml
new file mode 100644
index 0000000000..d9327a731d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-lo/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ ເມນູ
+ ລົບລ້າງ
+
+ ການປ້ອງກັນການຕິດຕາມກຳລັງເປີດຢູ່
+
+ ການປ້ອງກັນການຕິດຕາມໄດ້ບັອກຕົວຕິດຕາມ
+
+ ການປ້ອງກັນການຕິດຕາມໄດ້ປິດສຳລັບເວັບໄຊທນີ້
+
+ ຂໍ້ມູນເວັບໄຊ
+
+ ກຳລັງໂຫລດ
+
+ ເນື້ອຫາບາງອັນຖືກບລັອກໂດຍການຕັ້ງຄ່າການຫຼີ້ນອັດຕະໂນມັດ
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-lt/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-lt/strings.xml
new file mode 100644
index 0000000000..433b57f18e
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-lt/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Meniu
+ Išvalyti
+
+ Apsauga nuo stebėjimo įjungta
+
+ Apsaugo nuo stebėjimo užblokavo stebėjimo elementus
+
+ Apsauga nuo stebėjimo šioje svetainėje išjungta
+
+ Svetainės informacija
+
+ Įkeliama
+
+ Dalį turinio užblokavo automatinio grojimo nuostatos
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-mix/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-mix/strings.xml
new file mode 100644
index 0000000000..1636f653d5
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-mix/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Katsi
+ Ku^un
+
+ Chika va^a ña sau
+
+ Chika va^a ña sau
+
+ Chika va^a ña sau nu pagina yo^o
+
+ Tu^tu sitio yo^o
+
+ Sachuin
+
+ Ma ku kunu ña ku reproducción automática
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ml/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ml/strings.xml
new file mode 100644
index 0000000000..9f376452a4
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ml/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+ മെനു
+ മായ്ക്കുക
+
+ ട്രാക്കിങ്ങ് സംരക്ഷണം ഓൺ ആണ്
+
+ ട്രാക്കിംഗ് സംരക്ഷണം ട്രാക്കറുകളെ തടഞ്ഞു
+
+ ഈ സൈറ്റിന് ട്രാക്കിംഗ് പരിരക്ഷ ഇപ്പോൾ ഓഫാണ്
+
+ സൈറ്റ് വിവരങ്ങള്
+
+ ലഭ്യമാക്കുന്നു
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-mr/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-mr/strings.xml
new file mode 100644
index 0000000000..5430ce7327
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-mr/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+ मेनू
+ साफ करा
+
+ ट्रॅकिंग संरक्षण चालू आहे
+
+ ट्रॅकिंग संरक्षणने ट्रॅकर्स अवरोधित केले आहेत
+
+ या साइटसाठी ट्रॅकिंग संरक्षण बंद आहे
+
+ साईट माहिती
+
+ लोड होत आहे
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-my/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-my/strings.xml
new file mode 100644
index 0000000000..006be61ee2
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-my/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ စာရင်း
+ ရှင်းလင်းပါ
+
+ ခြေရာခံကာကွယ်မှုကို ဖွင့်ထားသည်
+
+ ခြေရာခံကာကွယ်မှုသည် ခြေရာခံသူများကိုပိတ်ဆို့ထားသည်။
+
+ ဒီ site အတွက်အကာအကွယ်ပေးမှုကိုပိတ်ထားသည်
+
+ ဆိုက်အချက်အလက်
+
+ အလုပ်လုပ်နေတယ်
+
+ အလိုအလျောက်ဖွင့်ခြင်း ဆက်တင်မှ အကြောင်းအရာအချို့အား ပိတ်ထားသည်။
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-nb-rNO/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-nb-rNO/strings.xml
new file mode 100644
index 0000000000..9d9bcbfc13
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-nb-rNO/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Meny
+
+ Tøm
+
+ Sporingsbeskyttelse er på
+
+ Sporingsbeskyttelse har blokkert sporere
+
+ Sporingsbeskyttelse er slått av for dette nettstedet
+
+ Informasjon om nettstedet
+
+ Laster
+
+ Noe av innholdet er blokkert av autoavspillings-innstillingene
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ne-rNP/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ne-rNP/strings.xml
new file mode 100644
index 0000000000..6a4341aafd
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ne-rNP/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ मेनु
+ खाली गर्नुहोस्
+
+ ट्र्याकिंग संरक्षण सक्रिय छ
+
+ ट्र्याकिङ्ग सुरक्षाले ट्रयाकरहरुलाई रोकेको छ
+
+ हाल यस साइटको लागी ट्रयाकिङ् सुरक्षा बन्द गरिएको छ।
+
+ साइट जानकारी
+
+ लोड हुँदैछ
+
+ केहि सामग्री स्वतः प्ले सेटिङ्ग द्वारा रोकिएको छ
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-nl/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-nl/strings.xml
new file mode 100644
index 0000000000..fee481b12f
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-nl/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menu
+
+ Wissen
+
+ Bescherming tegen volgen is ingeschakeld
+
+ Bescherming tegen volgen heeft trackers geblokkeerd
+
+ Bescherming tegen volgen is uit voor deze website
+
+ Website-informatie
+
+ Laden
+
+ Sommige inhoud is geblokkeerd door de instelling voor automatisch afspelen
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-nn-rNO/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-nn-rNO/strings.xml
new file mode 100644
index 0000000000..c77b3373d0
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-nn-rNO/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Meny
+
+ Tøm
+
+ Sporingsvern er på
+
+ Sporingsvern har blokkert sporarar
+
+ Sporingsvern er slått av for denne nettstaden
+
+ Informasjon om nettstaden
+
+ Lastar
+
+ Noko innhald har vorte blokkert av autoavspelings-innstillingane
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-oc/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-oc/strings.xml
new file mode 100644
index 0000000000..d2a72f26c1
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-oc/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menú
+
+ Escafar
+
+ La proteccion contra lo seguiment es activada
+
+ La proteccion contra lo seguiment a blocat de traçadors
+
+ La proteccion contra lo seguiment es desactivada per aqueste site
+
+ Informacions del site
+
+ Cargament
+
+ Una part del contengut es estada blocada per la configuracion de la lectura automatica
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-or/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-or/strings.xml
new file mode 100644
index 0000000000..9ca67e8959
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-or/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+ ମେନୁ
+ ଖାଲି କରନ୍ତୁ
+
+ ଟ୍ରାକିଂ ସୁରକ୍ଷା ଚାଲୁଛି
+
+ ଟ୍ରାକିଂ ସୁରକ୍ଷା ଟ୍ରାକରଗୁଡ଼ିକୁ ରୋକି ଦେଇଛି
+
+ ଟ୍ରାକିଂ ସୁରକ୍ଷା ଏହି ସାଇଟ ପାଇଁ ବନ୍ଦ ଅଛି
+
+ ସାଇଟ ସୂଚନା
+
+ ଧାରଣ କରୁଅଛି
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-pa-rIN/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-pa-rIN/strings.xml
new file mode 100644
index 0000000000..10a68745b3
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-pa-rIN/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ ਮੇਨੂ
+
+ ਸਾਫ਼ ਕਰੋ
+
+ ਟਰੈਕ ਕਰਨ ਤੋਂ ਸੁਰੱਖਿਆ ਚਾਲੂ ਹੈ
+
+ ਟਰੈਕਿੰਗ ਸੁਰੱਖਿਆ ਟਰੈਕਾਂ ਉੱਤੇ ਪਾਬੰਦੀ ਲਗਾਉਂਦੀ ਹੈ
+
+ ਇਸ ਸਾਈਟ ਲਈ ਟਰੈਕਿੰਗ ਸੁਰੱਖਿਆ ਬੰਦ ਹੈ
+
+ ਸਾਈਟ ਜਾਣਕਾਰੀ
+
+ ਲੋਡ ਹੋ ਰਿਹਾ ਹੈ
+
+ ਕੁਝ ਸਮੱਗਰੀ ਨੂੰ ਆਪੇ-ਪਲੇਅ ਦੀ ਸੈਟਿੰਗ ਰਾਹੀਂ ਪਾਬੰਦੀ ਲਾਈ ਹੈ
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-pa-rPK/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-pa-rPK/strings.xml
new file mode 100644
index 0000000000..f48af403b2
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-pa-rPK/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ مینو
+ صاف کرو
+
+ ٹوہ لاوݨ توں سرکھیا چالو اے
+
+ ٹوہ لاوݨ توں کجھ روک لاۓ گئے ہن
+
+ ٹوہ لاوݨ توں سرکھیا بند ہو گیا
+
+ سائٹ جاݨکاری
+
+ لوڈ کیتا جا رہا اے
+
+ خود بخود چلݨ نال کجھ وستوآں روکے گئے
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-pl/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-pl/strings.xml
new file mode 100644
index 0000000000..083dfd9aa9
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-pl/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menu
+
+ Wyczyść
+
+ Ochrona przed śledzeniem jest włączona
+
+ Ochrona przed śledzeniem zablokowała elementy śledzące
+
+ Ochrona przed śledzeniem jest wyłączona na tej witrynie
+
+ Informacje o witrynie
+
+ Wczytywanie
+
+ Część treści została zablokowana przez ustawienie automatycznego odtwarzania
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-pt-rBR/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-pt-rBR/strings.xml
new file mode 100644
index 0000000000..135bacdc26
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-pt-rBR/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menu
+
+ Limpar
+
+ Proteção contra rastreamento ativada
+
+ A proteção contra rastreamento bloqueou rastreadores
+
+ A proteção contra rastreamento está desativada neste site
+
+ Informações do site
+
+ Carregando
+
+ Algum conteúdo foi bloqueado pela configuração de reprodução automática
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-pt-rPT/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-pt-rPT/strings.xml
new file mode 100644
index 0000000000..bcd168a481
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-pt-rPT/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menu
+
+ Limpar
+
+ Proteção contra monitorização está ativada
+
+ A proteção contra a monitorização bloqueou rastreadores
+
+ A proteção contra monitorização está desativada para este site
+
+ Informação do site
+
+ A carregar
+
+ Algum conteúdo foi bloqueado pela configuração de reprodução automática
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-rm/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-rm/strings.xml
new file mode 100644
index 0000000000..d583fd3c77
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-rm/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menu
+
+ Stizzar
+
+ La protecziun cunter il fastizar è activa
+
+ La protecziun cunter il fastizar ha bloccà fastizaders
+
+ La protecziun cunter il fastizar è deactivada per questa website
+
+ Infurmaziuns davart la website
+
+ Chargiar
+
+ Tschert cuntegn è vegnì bloccà dal parameter da la reproducziun automatica
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ro/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ro/strings.xml
new file mode 100644
index 0000000000..72dee0c902
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ro/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+ Meniu
+ Șterge
+
+ Protecția împotriva urmăririi este activată
+
+ Protecția împotriva urmăririi a blocat elementele de urmărire
+
+ Protecția împotriva urmăririi este dezactivată pentru acest site
+
+ Informații despre site
+
+ Se încarcă
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ru/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ru/strings.xml
new file mode 100644
index 0000000000..f722325b3a
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ru/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Меню
+
+ Очистить
+
+ Защита от отслеживания включена
+
+ Защита от отслеживания заблокировала трекеры
+
+ Защита от отслеживания отключена для этого сайта
+
+ Сведения о сайте
+
+ Загрузка
+
+ Некоторое содержимое было заблокировано настройками автовоспроизведения
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sat/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sat/strings.xml
new file mode 100644
index 0000000000..f363d29490
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sat/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ ᱢᱮᱱᱩ
+
+ ᱯᱷᱟᱨᱪᱟ
+
+ ᱯᱟᱸᱡᱟ ᱟᱰ ᱪᱟᱹᱞᱩ ᱢᱮᱱᱟᱜ-ᱟ
+
+ ᱯᱟᱸᱡᱟ ᱨᱚᱯᱷᱟ ᱯᱟᱧᱡᱟ ᱫᱟᱱᱟᱲ ᱠᱚ ᱟᱠᱚᱴ ᱠᱮᱜᱼᱟᱭ
+
+ ᱯᱟᱸᱡᱟ ᱨᱚᱯᱷᱟ ᱵᱚᱸᱫᱚ ᱢᱮᱱᱟᱜ-ᱟ
+
+ ᱥᱟᱭᱤᱴ ᱨᱮᱭᱟᱜ ᱠᱷᱚᱵᱚᱨ
+
+ ᱞᱟᱫᱮᱜ ᱠᱟᱱᱟ
+
+ ᱟᱡ ᱛᱮ ᱮᱛᱦᱚᱵ ᱥᱟᱡᱟᱣ ᱠᱚ ᱠᱷᱟᱹᱛᱤᱨ ᱛᱮ ᱠᱤᱪᱷᱤ ᱡᱤᱱᱤᱥ ᱠᱚ ᱵᱞᱚᱠ ᱠᱟᱱᱟ
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sc/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sc/strings.xml
new file mode 100644
index 0000000000..f0b375f172
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sc/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menù
+
+ Isbòida
+
+ S’amparu contra sa sighidura est ativu
+
+ S’amparu contra sa sighidura at blocadu sighidores
+
+ Sa protetzione contra sa sighidura est disativada pro custu situ
+
+ Informatziones de su situ
+
+ Carrighende
+
+ Sa funtzionalidade de riprodutzione automàtica at blocadu cuntenutos
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-si/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-si/strings.xml
new file mode 100644
index 0000000000..52a9db1744
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-si/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ වට්ටෝරුව
+
+ මකන්න
+
+ ලුහුබැඳීමේ රැකවරණය සක්රියයි
+
+ ලුහුබැඳීමේ රැකවරණයෙන් අවහිරයි
+
+ අඩවිය සඳහා ලුහුබැඳීමේ රැකවරණය අබලයි
+
+ අඩවියේ තොරතුරු
+
+ පූරණය වෙමින්
+
+ ස්වයං වාදන සැකසුම මගින් ඇතැම් අන්තර්ගත අවහිර වී ඇත
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sk/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sk/strings.xml
new file mode 100644
index 0000000000..d60ecd8152
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sk/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Ponuka
+
+ Vymazať
+
+ Ochrana pred sledovaním je zapnutá
+
+ Ochrana pred sledovaním zablokovala sledovacie prvky
+
+ Ochrana pred sledovaním je na tejto stránke vypnutá
+
+ Informácie o stránke
+
+ Načítava sa
+
+ Niektorý obsah bol zablokovaný nastavením automatického prehrávania
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-skr/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-skr/strings.xml
new file mode 100644
index 0000000000..ccf404cabd
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-skr/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ مینیو
+
+ صاف کرو
+
+ سراغ کاری تحفظ چالو ہے
+
+ سراغ کاری تحفظ نے سُراغ رساں کوں بلاک کر ݙتا ہے
+
+ سراغ کاری تحفظ ایں سائٹ کیتے بند ہے
+
+ سائٹ ڄاݨکاری
+
+ لوڈ تھیندا پئے
+
+ کجھ مواد کوں آٹو پلے ترتیباں نال بلاک کر ݙتا ڳئے
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sl/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sl/strings.xml
new file mode 100644
index 0000000000..0d7a545930
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sl/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Meni
+
+ Počisti
+
+ Zaščita pred sledenjem je vključena
+
+ Zaščita pred sledenjem je zavrnila sledilce
+
+ Zaščita pred sledenjem je za to spletno mesto izključena
+
+ Podatki o strani
+
+ Nalaganje
+
+ Nastavitev samodejnega predvajanja je zavrnila nekaj vsebine
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sq/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sq/strings.xml
new file mode 100644
index 0000000000..33b69257a5
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sq/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menu
+
+ Spastroje
+
+ Mbrojtje Nga Gjurmimet është aktive
+
+ Mbrojtja Nga Gjurmimet ka bllokuar gjurmues
+
+ Mbrojtja Nga Gjurmimet është e çaktivizuar për këtë sajt
+
+ Hollësi sajti
+
+ Po ngarkohet
+
+ Është bllokuar lëndë nga rregullimi i vetëluajtjes
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sr/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sr/strings.xml
new file mode 100644
index 0000000000..5e70cab0c1
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sr/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Мени
+ Обриши
+
+ Заштита од праћења је укључена
+
+ Заштита од праћења је блокирала пратиоце
+
+ Заштита од праћења је искључена за ову страницу
+
+ Информације о страници
+
+ Учитавање
+
+ Неки садржај је блокиран због подешавања аутоматске репродукције
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-su/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-su/strings.xml
new file mode 100644
index 0000000000..af9bfdc6b0
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-su/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menu
+
+ Beresihan
+
+ Kilung Palacakan keur hurung
+
+ Kilung Palacakan geus meungpeuk palacak
+
+ Kilung Palacakan pareum pikeun ieu loka
+
+ Émbaran loka
+
+ Ngamuat
+
+ Sababaraha kontén dipeungpeuk ku setélan otoplay
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sv-rSE/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sv-rSE/strings.xml
new file mode 100644
index 0000000000..8922e20f5e
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-sv-rSE/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Meny
+
+ Rensa
+
+ Spårningsskydd är på
+
+ Spårningsskydd har blockerat spårare
+
+ Spårningsskydd är avstängt för den här webbplatsen
+
+ Webbplatsinformation
+
+ Laddar
+
+ En del innehåll har blockerats av inställningen för automatisk uppspelning
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-szl/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-szl/strings.xml
new file mode 100644
index 0000000000..e6537d32b1
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-szl/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Myni
+ Wypucuj
+
+ Ôchrōna ôd śledzynio je załōnczōno
+
+ Ôchrōna ôd śledzynio szperuje śledzōnce elymynty
+
+ Na tyj strōnie ôchrōna ôd śledzynio je wyłōnczōno
+
+ Informacyje ô strōnie
+
+ Ladowanie
+
+ Nasztalowanie autōmatycznego puszczanio zaszperowało kōnsek zawartości
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ta/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ta/strings.xml
new file mode 100644
index 0000000000..9b6e317f6c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ta/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ பட்டி
+ துடை
+
+ தடமறியல் பாதுகாப்பு இயக்கத்தில்
+
+ தடமறியல் பாதுகாப்பு தடமறிவான்களை முடக்கியது
+
+ தடமறியல் பாதுகாப்பு இத்தளத்தில் அணைக்கப்பட்டுள்ளது
+
+ தளத்தகவல்கள்
+
+ ஏற்றுகிறது
+
+ தன்னியக்க அமைப்பால் சில உள்ளடக்கம் தடுக்கப்பட்டுள்ளது
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-te/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-te/strings.xml
new file mode 100644
index 0000000000..504c84b58d
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-te/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ మెనూ
+ తుడిచివేయి
+
+ ట్రాకింగ్ సంరక్షణ చేతనంగా ఉంది
+
+ ట్రాకింగ్ సంరక్షణ ట్రాకర్లను నిరోధించింది
+
+ ఈ సైటుకి ట్రాకింగ్ సంరక్షణ ఆపివేయబడింది
+
+ సైటు సమాచారం
+
+ వస్తోంది
+
+ ఆటోప్లే అమరిక ద్వారా కొంత కంటెంట్ నిరోధించబడింది
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tg/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tg/strings.xml
new file mode 100644
index 0000000000..0fed273e55
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tg/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Меню
+
+ Пок кардан
+
+ Муҳофизат аз пайгирӣ фаъол аст
+
+ Муҳофизат аз пайгирӣ васоити пайгириро манъ кард
+
+ Муҳофизат аз пайгирӣ барои ин сомона хомӯш аст
+
+ Маълумот дар бораи сомона
+
+ Бор шуда истодааст
+
+ Баъзеи муҳтаво тавассути танзими пахши худкор манъ карда шуд
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-th/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-th/strings.xml
new file mode 100644
index 0000000000..55f06924fa
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-th/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ เมนู
+ ล้าง
+
+ การป้องกันการติดตามเปิดอยู่
+
+ การป้องกันการติดตามได้ปิดกั้นตัวติดตาม
+
+ การป้องกันการติดตามปิดอยู่สำหรับไซต์นี้
+
+ ข้อมูลไซต์
+
+ กำลังโหลด
+
+ เนื้อหาบางส่วนถูกปิดกั้นด้วยการตั้งค่าเล่นอัตโนมัติ
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tl/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tl/strings.xml
new file mode 100644
index 0000000000..4e5e6b3300
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tl/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Menu
+ Burahin
+
+ Nakabukas ang Tracking Protection
+
+ May naharang na mga tracker ang Tracking Protection
+
+ Nakasara ang Tracking Protection sa site na ito
+
+ Impormasyon sa site
+
+ Naglo-load
+
+ May ilang content na naharang dahil sa autoplay setting
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tok/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tok/strings.xml
new file mode 100644
index 0000000000..d9f3149d1b
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tok/strings.xml
@@ -0,0 +1,14 @@
+
+
+ o weka ale
+
+ lipu li ken ala lukin e sona sina
+
+ lipu li weka e lipu lukin
+
+ lipu ni la weka pi lipu lukin li lon ala
+
+ sona lipu
+
+ o awen
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tr/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tr/strings.xml
new file mode 100644
index 0000000000..904675f6e9
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tr/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menü
+
+ Temizle
+
+ İzlenme koruması açık
+
+ İzlenme koruması, takip kodlarını engelledi
+
+ Bu sitede izlenme koruması kapalı
+
+ Site bilgileri
+
+ Yükleniyor
+
+ Otomatik oynatma ayarınız bazı içerikleri engelledi
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-trs/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-trs/strings.xml
new file mode 100644
index 0000000000..ef86e00be9
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-trs/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Menû
+ Nā\'nïn\'
+
+ Sa narrán riña sa naga\'nāj a
+
+ Narrán sa dugumî ñù\' riña nej sa naga\'nāj a
+
+ Nitāj si \'iaj sun sa narán riña sa naga\'nāj a riña sitiô nan
+
+ Nuguan\' huā rayi\'î sitiô nan
+
+ Hìaj ayì\'ij
+
+ Huā da’āj nej sa mà riñaj narán gi’iaj guendâ nù sa duguachín man’an sa ni’io’
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tt/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tt/strings.xml
new file mode 100644
index 0000000000..3a463ee5cf
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tt/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Меню
+ Чистарту
+
+ Күзәтелүдән Саклау кабызылган
+
+ Күзәтүдән саклау күзәтеп торучыларны блоклады
+
+ Бу сайт өчен Күзәтелүдән Саклау сүндерелгән
+
+ Сайт турында мәгълүмат
+
+ Йөкләү
+
+ Автоуйнату көйләүләре аркасында кайбер эчтәлекләр блокланды
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tzm/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tzm/strings.xml
new file mode 100644
index 0000000000..d451c5972c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-tzm/strings.xml
@@ -0,0 +1,10 @@
+
+
+
+ Umuɣ
+ Sfeḍ
+
+ Asmel n wasit
+
+ Asali
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ug/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ug/strings.xml
new file mode 100644
index 0000000000..89e5173df3
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ug/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ تىزىملىك
+
+ تازىلا
+
+ ئىزلاشتىن توسۇش ئىقتىدارى ئوچۇق
+
+ ئىز قوغلاش قوغدىغۇچىسى ئىز قوغلىغۇچىلارنى توستى
+
+ بۇ توربېكەتكە نىسبەتەن ئىزلاشتىن قوغداش تاقاق
+
+ بېكەت ئۇچۇرى
+
+ يۈكلەۋاتىدۇ
+
+ بەزى مەزمۇنلارنى ئاپتوماتىك قويۇش تەڭشىكى توستى
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-uk/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-uk/strings.xml
new file mode 100644
index 0000000000..1460d015dd
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-uk/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Меню
+
+ Очистити
+
+ Захист від стеження увімкнено
+
+ Захист від стеження заблокував стеження
+
+ Захист від стеження вимкнено для цього сайту
+
+ Інформація про сайт
+
+ Завантаження
+
+ Деякий вміст заблоковано налаштуванням автовідтворення
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ur/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ur/strings.xml
new file mode 100644
index 0000000000..78470d254c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-ur/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ مینیو
+ صاف کریں
+
+ سراغ کاری تحفظ چالو ہے
+
+ سراغ کاری تحفظ نے سُراغ رساں کو مسدود کردیا ہے
+
+ سراغ کاری تحفظ اس سائٹ کے لیئے بند ہے
+
+ سائٹ کی معلومات
+
+ لوڈ کر رہا ہے
+
+ کچھ مشمولات کو آٹو پلے سیٹنگ سے مسدود کردیا گیا ہے
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-uz/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-uz/strings.xml
new file mode 100644
index 0000000000..ec69af71f6
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-uz/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Menyu
+ Tozalash
+
+ Kuzatuvdan himoya yoniq
+
+ Kuzatuvdan himoya funksiyasi kuzatuvchilarni blokladi
+
+ Bu sayt uchun kuzatuvdan himoya funksiyasi oʻchirilgan
+
+ Sayt maʼlumoti
+
+ Yuklanmoqda
+
+ Avtomatik ishga tushirish sozlamasi tufayli ayrim kontentlar bloklandi
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-vec/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-vec/strings.xml
new file mode 100644
index 0000000000..15a625bcbc
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-vec/strings.xml
@@ -0,0 +1,16 @@
+
+
+
+ Menu
+ Pulisi
+
+ Ƚa protesion antitraciamento ƚa xe ativa
+
+ Ƚa protesion antitraciamento ƚa ga blocà contenudi tracianti
+
+ Ƚa protesion antitraciamento ƚa xe disativà par sto sito
+
+ Informasioni sito
+
+ Cargamento
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-vi/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-vi/strings.xml
new file mode 100644
index 0000000000..c3c54456ed
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-vi/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ Menu
+
+ Xóa
+
+ Trình chống theo dõi đang bật
+
+ Trình chống theo dõi đã chặn trình theo dõi
+
+ Đã tắt Trình chống theo dõi cho trang web này
+
+ Thông tin về trang web
+
+ Đang tải
+
+ Một số nội dung đã bị chặn bởi cài đặt tự động phát
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-yo/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-yo/strings.xml
new file mode 100644
index 0000000000..6f46be1286
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-yo/strings.xml
@@ -0,0 +1,18 @@
+
+
+
+ Mẹ́nù
+ Paárẹ́
+
+ Ìtọpinpin Ìdàábòbò wà ní títàn
+
+ Ìtọpinpin ìdàábòbò ti dènà atọpinpin
+
+ Ìtọpinpin ìdàábòbò ti di pípa fún ìkànnì yìí
+
+ Ìfitóniléti ìkànnì
+
+ Ó ń gbáradì
+
+ Àwọn àkòónú kan ti di dídénà ààtò ìfi-ara-ẹni-ṣisẹ́
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-zh-rCN/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 0000000000..3cb765dc23
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ 菜单
+
+ 清除
+
+ 已开启跟踪保护
+
+ 跟踪保护已拦截跟踪器
+
+ 已关闭对此网站的跟踪保护
+
+ 网站信息
+
+ 正在加载
+
+ 某些内容已被自动播放设置阻止
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-zh-rTW/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-zh-rTW/strings.xml
new file mode 100644
index 0000000000..d1d023f3db
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values-zh-rTW/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ 選單
+
+ 清除
+
+ 追蹤保護功能已開啟
+
+ 追蹤保護功能已封鎖追蹤器
+
+ 已關閉針對此網站的追蹤保護功能
+
+ 網站資訊
+
+ 載入中
+
+ 某些內容已由自動播放設定封鎖
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values/attrs_browser_toolbar.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values/attrs_browser_toolbar.xml
new file mode 100644
index 0000000000..431bb6b080
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values/attrs_browser_toolbar.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values/dimens.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values/dimens.xml
new file mode 100644
index 0000000000..23fd755686
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values/dimens.xml
@@ -0,0 +1,31 @@
+
+
+
+ 56dp
+
+
+ 3dp
+ 24dp
+ 1dp
+ 4dp
+ 24dp
+ 12dp
+ 24dp
+ 16dp
+ 0dp
+
+
+ 8dp
+ 0dp
+ 8dp
+ 16dp
+
+ 15sp
+ 15sp
+ 12sp
+
+ 48dp
+ 48dp
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values/ids.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values/ids.xml
new file mode 100644
index 0000000000..20e28a0c58
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values/ids.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/main/res/values/strings.xml b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..c845e189e7
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/main/res/values/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ Menu
+
+ Clear
+
+ Tracking Protection is on
+
+ Tracking Protection has blocked trackers
+
+ Tracking Protection is off for this site
+
+ Site information
+
+ Loading
+
+ Some content has been blocked by the autoplay setting
+
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/AsyncFilterListenerTest.kt b/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/AsyncFilterListenerTest.kt
new file mode 100644
index 0000000000..6c95c8f243
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/AsyncFilterListenerTest.kt
@@ -0,0 +1,350 @@
+/* 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.browser.toolbar2
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.async
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.test.runTest
+import mozilla.components.concept.toolbar.AutocompleteDelegate
+import mozilla.components.concept.toolbar.AutocompleteResult
+import mozilla.components.support.test.mock
+import mozilla.components.ui.autocomplete.AutocompleteView
+import mozilla.components.ui.autocomplete.InlineAutocompleteEditText
+import org.junit.Assert.assertEquals
+import org.junit.Assert.fail
+import org.junit.Test
+import org.mockito.Mockito.atLeast
+import org.mockito.Mockito.atLeastOnce
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+import java.util.concurrent.Executor
+
+@ExperimentalCoroutinesApi // for runTest
+class AsyncFilterListenerTest {
+ @Test
+ fun `filter listener cancels prior filter executions`() = runTest {
+ val urlView: AutocompleteView = mock()
+ val filter: suspend (String, AutocompleteDelegate) -> Unit = mock()
+
+ val dispatcher = spy(
+ Executor {
+ it.run()
+ }.asCoroutineDispatcher(),
+ )
+
+ val listener = AsyncFilterListener(urlView, dispatcher, filter)
+
+ verify(dispatcher, never()).cancelChildren()
+
+ listener("test")
+
+ verify(dispatcher, atLeastOnce()).cancelChildren()
+ }
+
+ @Test
+ fun `filter delegate checks for cancellations before it runs, passes results to autocomplete view`() = runTest {
+ var filter: suspend (String, AutocompleteDelegate) -> Unit = { query, delegate ->
+ assertEquals("test", query)
+ delegate.applyAutocompleteResult(
+ AutocompleteResult(
+ input = "test",
+ text = "testing.com",
+ url = "http://www.testing.com",
+ source = "asyncTest",
+ totalItems = 1,
+ ),
+ )
+ }
+
+ val dispatcher = spy(
+ Executor {
+ it.run()
+ }.asCoroutineDispatcher(),
+ )
+
+ var didCallApply = 0
+
+ var listener = AsyncFilterListener(
+ object : AutocompleteView {
+ override val originalText: String = "test"
+
+ override fun applyAutocompleteResult(result: InlineAutocompleteEditText.AutocompleteResult) {
+ assertEquals("asyncTest", result.source)
+ assertEquals("testing.com", result.text)
+ assertEquals(1, result.totalItems)
+ didCallApply += 1
+ }
+
+ override fun noAutocompleteResult() {
+ fail()
+ }
+ },
+ dispatcher,
+ filter,
+ this.coroutineContext,
+ )
+
+ verify(dispatcher, never()).isActive
+
+ async { listener("test") }.await()
+
+ // Checked if parent scope is still active. Somehow, each access to 'isActive' registers as 4?
+ verify(dispatcher, atLeast(4)).isActive
+ // Passed the result to the view's apply method exactly once.
+ assertEquals(1, didCallApply)
+
+ filter = { query, delegate ->
+ assertEquals("moz", query)
+ delegate.applyAutocompleteResult(
+ AutocompleteResult(
+ input = "moz",
+ text = "mozilla.com",
+ url = "http://www.mozilla.com",
+ source = "asyncTestTwo",
+ totalItems = 2,
+ ),
+ )
+ }
+ listener = AsyncFilterListener(
+ object : AutocompleteView {
+ override val originalText: String = "moz"
+
+ override fun applyAutocompleteResult(result: InlineAutocompleteEditText.AutocompleteResult) {
+ assertEquals("asyncTestTwo", result.source)
+ assertEquals("mozilla.com", result.text)
+ assertEquals(2, result.totalItems)
+ didCallApply += 1
+ }
+
+ override fun noAutocompleteResult() {
+ fail()
+ }
+ },
+ dispatcher,
+ filter,
+ this.coroutineContext,
+ )
+
+ async { listener("moz") }.await()
+
+ verify(dispatcher, atLeast(8)).isActive
+ assertEquals(2, didCallApply)
+ }
+
+ @Test
+ fun `delegate discards stale results`() = runTest {
+ val filter: suspend (String, AutocompleteDelegate) -> Unit = { query, delegate ->
+ assertEquals("test", query)
+ delegate.applyAutocompleteResult(
+ AutocompleteResult(
+ input = "test",
+ text = "testing.com",
+ url = "http://www.testing.com",
+ source = "asyncTest",
+ totalItems = 1,
+ ),
+ )
+ }
+
+ val dispatcher = Executor {
+ it.run()
+ }.asCoroutineDispatcher()
+
+ val listener = AsyncFilterListener(
+ object : AutocompleteView {
+ override val originalText: String = "nolongertest"
+
+ override fun applyAutocompleteResult(result: InlineAutocompleteEditText.AutocompleteResult) {
+ fail()
+ }
+
+ override fun noAutocompleteResult() {
+ fail()
+ }
+ },
+ dispatcher,
+ filter,
+ this.coroutineContext,
+ )
+
+ listener("test")
+ }
+
+ @Test
+ fun `delegate discards stale lack of results`() = runTest {
+ val filter: suspend (String, AutocompleteDelegate) -> Unit = { query, delegate ->
+ assertEquals("test", query)
+ delegate.noAutocompleteResult("test")
+ }
+
+ val dispatcher = Executor {
+ it.run()
+ }.asCoroutineDispatcher()
+
+ val listener = AsyncFilterListener(
+ object : AutocompleteView {
+ override val originalText: String = "nolongertest"
+
+ override fun applyAutocompleteResult(result: InlineAutocompleteEditText.AutocompleteResult) {
+ fail()
+ }
+
+ override fun noAutocompleteResult() {
+ fail()
+ }
+ },
+ dispatcher,
+ filter,
+ this.coroutineContext,
+ )
+
+ listener("test")
+ }
+
+ @Test
+ fun `delegate passes through non-stale lack of results`() = runTest {
+ val filter: suspend (String, AutocompleteDelegate) -> Unit = { query, delegate ->
+ assertEquals("test", query)
+ delegate.noAutocompleteResult("test")
+ }
+
+ val dispatcher = Executor {
+ it.run()
+ }.asCoroutineDispatcher()
+
+ var calledNoResults = 0
+ val listener = AsyncFilterListener(
+ object : AutocompleteView {
+ override val originalText: String = "test"
+
+ override fun applyAutocompleteResult(result: InlineAutocompleteEditText.AutocompleteResult) {
+ fail()
+ }
+
+ override fun noAutocompleteResult() {
+ calledNoResults += 1
+ }
+ },
+ dispatcher,
+ filter,
+ this.coroutineContext,
+ )
+
+ async { listener("test") }.await()
+
+ assertEquals(1, calledNoResults)
+ }
+
+ @Test
+ fun `delegate discards results if parent scope was cancelled`() = runTest {
+ var preservedDelegate: AutocompleteDelegate? = null
+
+ val filter: suspend (String, AutocompleteDelegate) -> Unit = { query, delegate ->
+ preservedDelegate = delegate
+ assertEquals("test", query)
+ delegate.applyAutocompleteResult(
+ AutocompleteResult(
+ input = "test",
+ text = "testing.com",
+ url = "http://www.testing.com",
+ source = "asyncTest",
+ totalItems = 1,
+ ),
+ )
+ }
+
+ val dispatcher = Executor {
+ it.run()
+ }.asCoroutineDispatcher()
+
+ var calledResults = 0
+ val listener = AsyncFilterListener(
+ object : AutocompleteView {
+ override val originalText: String = "test"
+
+ override fun applyAutocompleteResult(result: InlineAutocompleteEditText.AutocompleteResult) {
+ assertEquals("asyncTest", result.source)
+ assertEquals("testing.com", result.text)
+ assertEquals(1, result.totalItems)
+ calledResults += 1
+ }
+
+ override fun noAutocompleteResult() {
+ fail()
+ }
+ },
+ dispatcher,
+ filter,
+ this.coroutineContext,
+ )
+
+ async {
+ listener("test")
+ listener("test")
+ }.await()
+
+ // This result application should be discarded, because scope has been cancelled by the second
+ // 'listener' call above.
+ preservedDelegate!!.applyAutocompleteResult(
+ AutocompleteResult(
+ input = "test",
+ text = "testing.com",
+ url = "http://www.testing.com",
+ source = "asyncCancelled",
+ totalItems = 1,
+ ),
+ )
+
+ assertEquals(2, calledResults)
+ }
+
+ @Test
+ fun `delegate discards lack of results if parent scope was cancelled`() = runTest {
+ var preservedDelegate: AutocompleteDelegate? = null
+
+ val filter: suspend (String, AutocompleteDelegate) -> Unit = { query, delegate ->
+ preservedDelegate = delegate
+ assertEquals("test", query)
+ delegate.noAutocompleteResult("test")
+ }
+
+ val dispatcher = Executor {
+ it.run()
+ }.asCoroutineDispatcher()
+
+ var calledResults = 0
+ val listener = AsyncFilterListener(
+ object : AutocompleteView {
+ override val originalText: String = "test"
+
+ override fun applyAutocompleteResult(result: InlineAutocompleteEditText.AutocompleteResult) {
+ fail()
+ }
+
+ override fun noAutocompleteResult() {
+ calledResults += 1
+ }
+ },
+ dispatcher,
+ filter,
+ this.coroutineContext,
+ )
+
+ async {
+ listener("test")
+ listener("test")
+ }.await()
+
+ // This "no results" call should be discarded, because scope has been cancelled by the second
+ // 'listener' call above.
+ preservedDelegate!!.noAutocompleteResult("test")
+
+ assertEquals(2, calledResults)
+ }
+}
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/BrowserToolbarTest.kt b/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/BrowserToolbarTest.kt
new file mode 100644
index 0000000000..d1a499e30c
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/BrowserToolbarTest.kt
@@ -0,0 +1,1044 @@
+/* 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.browser.toolbar2
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.util.AttributeSet
+import android.view.View
+import android.view.ViewParent
+import android.view.accessibility.AccessibilityEvent
+import android.view.accessibility.AccessibilityManager
+import android.widget.ImageButton
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.view.inputmethod.EditorInfoCompat
+import androidx.core.view.isGone
+import androidx.core.view.isVisible
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.toolbar2.display.DisplayToolbar
+import mozilla.components.browser.toolbar2.display.DisplayToolbarViews
+import mozilla.components.browser.toolbar2.display.MenuButton
+import mozilla.components.browser.toolbar2.edit.EditToolbar
+import mozilla.components.concept.toolbar.AutocompleteDelegate
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.concept.toolbar.Toolbar.SiteSecurity
+import mozilla.components.concept.toolbar.Toolbar.SiteTrackingProtection
+import mozilla.components.support.ktx.kotlin.MAX_URI_LENGTH
+import mozilla.components.support.test.argumentCaptor
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.whenever
+import mozilla.components.ui.widgets.behavior.EngineViewScrollingBehavior
+import mozilla.components.ui.widgets.behavior.ViewPosition
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+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.ArgumentCaptor
+import org.mockito.ArgumentMatchers
+import org.mockito.Mockito.any
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+import org.mockito.Mockito.`when`
+import org.robolectric.Robolectric
+import org.robolectric.Shadows.shadowOf
+
+@RunWith(AndroidJUnit4::class)
+class BrowserToolbarTest {
+
+ @Test
+ fun `display toolbar is visible by default`() {
+ val toolbar = BrowserToolbar(testContext)
+ assertTrue(toolbar.display.rootView.visibility == View.VISIBLE)
+ assertTrue(toolbar.edit.rootView.visibility == View.GONE)
+ }
+
+ @Test
+ fun `calling editMode() makes edit toolbar visible`() {
+ val toolbar = BrowserToolbar(testContext)
+ assertTrue(toolbar.display.rootView.visibility == View.VISIBLE)
+ assertTrue(toolbar.edit.rootView.visibility == View.GONE)
+
+ toolbar.editMode()
+
+ assertTrue(toolbar.display.rootView.visibility == View.GONE)
+ assertTrue(toolbar.edit.rootView.visibility == View.VISIBLE)
+ }
+
+ @Test
+ fun `calling displayMode() makes display toolbar visible`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.editMode()
+
+ assertTrue(toolbar.display.rootView.visibility == View.GONE)
+ assertTrue(toolbar.edit.rootView.visibility == View.VISIBLE)
+
+ toolbar.displayMode()
+
+ assertTrue(toolbar.display.rootView.visibility == View.VISIBLE)
+ assertTrue(toolbar.edit.rootView.visibility == View.GONE)
+ }
+
+ @Test
+ fun `back presses will not be handled in display mode`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.displayMode()
+
+ assertFalse(toolbar.onBackPressed())
+
+ assertTrue(toolbar.display.rootView.visibility == View.VISIBLE)
+ assertTrue(toolbar.edit.rootView.visibility == View.GONE)
+ }
+
+ @Test
+ fun `back presses will switch from edit mode to display mode`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.editMode()
+
+ assertTrue(toolbar.display.rootView.visibility == View.GONE)
+ assertTrue(toolbar.edit.rootView.visibility == View.VISIBLE)
+
+ assertTrue(toolbar.onBackPressed())
+
+ assertTrue(toolbar.display.rootView.visibility == View.VISIBLE)
+ assertTrue(toolbar.edit.rootView.visibility == View.GONE)
+ }
+
+ @Test
+ fun `displayUrl will be forwarded to display toolbar immediately`() {
+ val toolbar = BrowserToolbar(testContext)
+ val display: DisplayToolbar = mock()
+ val edit: EditToolbar = mock()
+
+ toolbar.display = display
+ toolbar.edit = edit
+
+ toolbar.url = "https://www.mozilla.org"
+
+ verify(display).url = "https://www.mozilla.org"
+ verify(edit, never()).updateUrl(ArgumentMatchers.anyString(), ArgumentMatchers.anyBoolean(), ArgumentMatchers.anyBoolean(), ArgumentMatchers.anyBoolean())
+ }
+
+ @Test
+ fun `displayUrl is truncated to prevent extreme cases from slowing down the UI`() {
+ val toolbar = BrowserToolbar(testContext)
+ val display: DisplayToolbar = mock()
+ val edit: EditToolbar = mock()
+
+ toolbar.display = display
+ toolbar.edit = edit
+
+ toolbar.url = "a".repeat(MAX_URI_LENGTH + 1)
+ toolbar.url = "b".repeat(MAX_URI_LENGTH)
+ toolbar.url = "c".repeat(MAX_URI_LENGTH - 1)
+
+ val urlCaptor = argumentCaptor()
+ verify(display, times(3)).url = urlCaptor.capture()
+
+ val capturedValues = urlCaptor.allValues
+ // Value was too long and should've been truncated
+ assertEquals("a".repeat(MAX_URI_LENGTH), capturedValues[0])
+ // Values should be the same as before
+ assertEquals("b".repeat(MAX_URI_LENGTH), capturedValues[1])
+ assertEquals("c".repeat(MAX_URI_LENGTH - 1), capturedValues[2])
+ }
+
+ @Test
+ fun `searchTerms is truncated in case it is greater than MAX_URI_LENGTH`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.edit = spy(toolbar.edit)
+ toolbar.editMode()
+
+ toolbar.setSearchTerms("a".repeat(MAX_URI_LENGTH + 1))
+
+ // Value was too long and should've been truncated
+ assertEquals(toolbar.searchTerms.length, MAX_URI_LENGTH)
+ verify(toolbar.edit).editSuggestion("a".repeat(MAX_URI_LENGTH))
+ }
+
+ @Test
+ fun `searchTerms is not truncated in case it is equal or less than MAX_URI_LENGTH`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.edit = spy(toolbar.edit)
+ toolbar.editMode()
+
+ toolbar.setSearchTerms("b".repeat(MAX_URI_LENGTH))
+
+ // Value should be the same as before
+ assertEquals(toolbar.searchTerms.length, MAX_URI_LENGTH)
+ verify(toolbar.edit).editSuggestion("b".repeat(MAX_URI_LENGTH))
+
+ toolbar.setSearchTerms("c".repeat(MAX_URI_LENGTH - 1))
+
+ // Value should be the same as before
+ assertEquals(toolbar.searchTerms.length, MAX_URI_LENGTH - 1)
+ verify(toolbar.edit).editSuggestion("c".repeat(MAX_URI_LENGTH - 1))
+ }
+
+ @Test
+ fun `last URL will be forwarded to edit toolbar when switching mode`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.edit = spy(toolbar.edit)
+
+ toolbar.url = "https://www.mozilla.org"
+ verify(toolbar.edit, never()).updateUrl("https://www.mozilla.org", false)
+
+ toolbar.editMode()
+
+ verify(toolbar.edit).updateUrl("https://www.mozilla.org", false)
+ }
+
+ @Test
+ fun `displayProgress will send accessibility events`() {
+ val toolbar = BrowserToolbar(testContext)
+ val root = mock(ViewParent::class.java)
+ shadowOf(toolbar).setMyParent(root)
+ `when`(root.requestSendAccessibilityEvent(any(), any())).thenReturn(false)
+
+ val shadowAccessibilityManager = shadowOf(testContext.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager)
+ shadowAccessibilityManager.setEnabled(true)
+ shadowAccessibilityManager.setTouchExplorationEnabled(true)
+
+ toolbar.displayProgress(10)
+ toolbar.displayProgress(50)
+ toolbar.displayProgress(100)
+
+ // make sure multiple calls to 100% does not trigger "loading" announcement
+ toolbar.displayProgress(100)
+
+ val captor = ArgumentCaptor.forClass(AccessibilityEvent::class.java)
+
+ verify(root, times(5)).requestSendAccessibilityEvent(any(), captor.capture())
+
+ assertEquals(AccessibilityEvent.TYPE_ANNOUNCEMENT, captor.allValues[0].eventType)
+ assertEquals(testContext.getString(R.string.mozac_browser_toolbar_progress_loading), captor.allValues[0].text[0])
+
+ assertEquals(AccessibilityEvent.TYPE_VIEW_SCROLLED, captor.allValues[1].eventType)
+ assertEquals(10, captor.allValues[1].scrollY)
+ assertEquals(100, captor.allValues[1].maxScrollY)
+
+ assertEquals(AccessibilityEvent.TYPE_VIEW_SCROLLED, captor.allValues[2].eventType)
+ assertEquals(50, captor.allValues[2].scrollY)
+ assertEquals(100, captor.allValues[2].maxScrollY)
+
+ assertEquals(AccessibilityEvent.TYPE_VIEW_SCROLLED, captor.allValues[3].eventType)
+ assertEquals(100, captor.allValues[3].scrollY)
+ assertEquals(100, captor.allValues[3].maxScrollY)
+
+ assertEquals(AccessibilityEvent.TYPE_VIEW_SCROLLED, captor.allValues[4].eventType)
+ assertEquals(100, captor.allValues[3].scrollY)
+ assertEquals(100, captor.allValues[3].maxScrollY)
+ }
+
+ @Test
+ fun `displayProgress will not send send view scrolled accessibility events if touch exploration is disabled`() {
+ val toolbar = BrowserToolbar(testContext)
+ val root = mock(ViewParent::class.java)
+ shadowOf(toolbar).setMyParent(root)
+ `when`(root.requestSendAccessibilityEvent(any(), any())).thenReturn(false)
+
+ val shadowAccessibilityManager = shadowOf(testContext.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager)
+ shadowAccessibilityManager.setEnabled(true)
+ shadowAccessibilityManager.setTouchExplorationEnabled(false)
+
+ toolbar.displayProgress(10)
+ toolbar.displayProgress(50)
+ toolbar.displayProgress(100)
+
+ // make sure multiple calls to 100% does not trigger "loading" announcement
+ toolbar.displayProgress(100)
+
+ val captor = ArgumentCaptor.forClass(AccessibilityEvent::class.java)
+
+ verify(root, times(1)).requestSendAccessibilityEvent(any(), captor.capture())
+
+ assertEquals(AccessibilityEvent.TYPE_ANNOUNCEMENT, captor.allValues[0].eventType)
+ assertEquals(testContext.getString(R.string.mozac_browser_toolbar_progress_loading), captor.allValues[0].text[0])
+ }
+
+ @Test
+ fun `displayProgress will be forwarded to display toolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+ val display: DisplayToolbar = mock()
+
+ toolbar.display = display
+
+ toolbar.displayProgress(10)
+ toolbar.displayProgress(50)
+ toolbar.displayProgress(75)
+ toolbar.displayProgress(100)
+
+ verify(display).updateProgress(10)
+ verify(display).updateProgress(50)
+ verify(display).updateProgress(75)
+ verify(display).updateProgress(100)
+
+ verifyNoMoreInteractions(display)
+ }
+
+ @Test
+ fun `internal onUrlEntered callback will be forwarded to urlChangeListener`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ val mockedListener = object {
+ var called = false
+ var url: String? = null
+
+ fun invoke(url: String): Boolean {
+ this.called = true
+ this.url = url
+ return true
+ }
+ }
+
+ toolbar.setOnUrlCommitListener(mockedListener::invoke)
+ toolbar.onUrlEntered("https://www.mozilla.org")
+
+ assertTrue(mockedListener.called)
+ assertEquals("https://www.mozilla.org", mockedListener.url)
+ }
+
+ /*
+ @Test
+ fun `internal onEditCancelled callback will be forwarded to editListener`() {
+ val toolbar = BrowserToolbar(testContext)
+ val listener: Toolbar.OnEditListener = mock()
+ toolbar.setOnEditListener(listener)
+ assertEquals(toolbar.edit.editListener, listener)
+
+ toolbar.edit.views.url.onKeyPreIme(
+ KeyEvent.KEYCODE_BACK,
+ KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK)
+ )
+ verify(listener, times(1)).onCancelEditing()
+ }*/
+
+ @Test
+ fun `toolbar measure will use full width and fixed 56dp height`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ val widthSpec = View.MeasureSpec.makeMeasureSpec(1024, View.MeasureSpec.AT_MOST)
+ val heightSpec = View.MeasureSpec.makeMeasureSpec(800, View.MeasureSpec.AT_MOST)
+
+ toolbar.measure(widthSpec, heightSpec)
+
+ assertEquals(1024, toolbar.measuredWidth)
+ assertEquals(56, toolbar.measuredHeight)
+ }
+
+ @Test
+ fun `toolbar will use provided height with EXACTLY measure spec`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ val widthSpec = View.MeasureSpec.makeMeasureSpec(1024, View.MeasureSpec.AT_MOST)
+ val heightSpec = View.MeasureSpec.makeMeasureSpec(800, View.MeasureSpec.EXACTLY)
+
+ toolbar.measure(widthSpec, heightSpec)
+
+ assertEquals(1024, toolbar.measuredWidth)
+ assertEquals(800, toolbar.measuredHeight)
+ }
+
+ @Test
+ fun `display and edit toolbar will use full size of browser toolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ assertEquals(0, toolbar.display.rootView.measuredWidth)
+ assertEquals(0, toolbar.display.rootView.measuredHeight)
+ assertEquals(0, toolbar.edit.rootView.measuredWidth)
+ assertEquals(0, toolbar.edit.rootView.measuredHeight)
+
+ val widthSpec = View.MeasureSpec.makeMeasureSpec(1024, View.MeasureSpec.AT_MOST)
+ val heightSpec = View.MeasureSpec.makeMeasureSpec(800, View.MeasureSpec.AT_MOST)
+
+ toolbar.measure(widthSpec, heightSpec)
+
+ assertEquals(1024, toolbar.display.rootView.measuredWidth)
+ assertEquals(56, toolbar.display.rootView.measuredHeight)
+ assertEquals(1024, toolbar.edit.rootView.measuredWidth)
+ assertEquals(56, toolbar.edit.rootView.measuredHeight)
+ }
+
+ @Test
+ fun `toolbar will switch back to display mode after an URL has been entered`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.editMode()
+
+ assertTrue(toolbar.display.rootView.visibility == View.GONE)
+ assertTrue(toolbar.edit.rootView.visibility == View.VISIBLE)
+
+ toolbar.onUrlEntered("https://www.mozilla.org")
+
+ assertTrue(toolbar.display.rootView.visibility == View.VISIBLE)
+ assertTrue(toolbar.edit.rootView.visibility == View.GONE)
+ }
+
+ @Test
+ fun `toolbar will switch back to display mode if URL commit listener returns true`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.setOnUrlCommitListener { true }
+ toolbar.editMode()
+
+ assertTrue(toolbar.display.rootView.isGone)
+ assertTrue(toolbar.edit.rootView.isVisible)
+
+ toolbar.onUrlEntered("https://www.mozilla.org")
+
+ assertTrue(toolbar.display.rootView.isVisible)
+ assertTrue(toolbar.edit.rootView.isGone)
+ }
+
+ @Test
+ fun `toolbar will stay in edit mode if URL commit listener returns false`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.setOnUrlCommitListener { false }
+ toolbar.editMode()
+
+ assertTrue(toolbar.display.rootView.isGone)
+ assertTrue(toolbar.edit.rootView.isVisible)
+
+ toolbar.onUrlEntered("https://www.mozilla.org")
+
+ assertTrue(toolbar.display.rootView.isGone)
+ assertTrue(toolbar.edit.rootView.isVisible)
+ }
+
+ @Test
+ fun `add browser action will be forwarded to display toolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+ val display: DisplayToolbar = mock()
+
+ toolbar.display = display
+
+ val action = BrowserToolbar.Button(mock(), "Hello") {
+ // Do nothing
+ }
+
+ toolbar.addBrowserAction(action)
+
+ verify(display).addBrowserAction(action)
+ }
+
+ @Test
+ fun `remove browser action will be forwarded to display toolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+ val display: DisplayToolbar = mock()
+
+ toolbar.display = display
+
+ val action = BrowserToolbar.Button(mock(), "Hello") {
+ // Do nothing
+ }
+
+ toolbar.removeBrowserAction(action)
+
+ verify(display).removeBrowserAction(action)
+ }
+
+ @Test
+ fun `remove navigation action will be forwarded to display toolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+ val display: DisplayToolbar = mock()
+
+ toolbar.display = display
+
+ val action = BrowserToolbar.Button(mock(), "Hello") {
+ // Do nothing
+ }
+
+ toolbar.removeNavigationAction(action)
+
+ verify(display).removeNavigationAction(action)
+ }
+
+ @Test
+ fun `remove page action will be forwarded to display toolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+ val display: DisplayToolbar = mock()
+
+ toolbar.display = display
+
+ val action = BrowserToolbar.Button(mock(), "Hello") {
+ // Do nothing
+ }
+
+ toolbar.removePageAction(action)
+
+ verify(display).removePageAction(action)
+ }
+
+ @Test
+ fun `add page action will be forwarded to display toolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ val display: DisplayToolbar = mock()
+
+ toolbar.display = display
+
+ val action = BrowserToolbar.Button(mock(), "World") {
+ // Do nothing
+ }
+
+ toolbar.addPageAction(action)
+
+ verify(display).addPageAction(action)
+ }
+
+ @Test
+ fun `add edit action start will be forwarded to edit toolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ val edit: EditToolbar = mock()
+ toolbar.edit = edit
+
+ val action = BrowserToolbar.Button(mock(), "QR code scanner") {
+ // Do nothing
+ }
+
+ toolbar.addEditActionStart(action)
+
+ verify(edit).addEditActionStart(action)
+ }
+
+ @Test
+ fun `add edit action end will be forwarded to edit toolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ val edit: EditToolbar = mock()
+ toolbar.edit = edit
+
+ val action = BrowserToolbar.Button(mock(), "QR code scanner") {
+ // Do nothing
+ }
+
+ toolbar.addEditActionEnd(action)
+
+ verify(edit).addEditActionEnd(action)
+ }
+
+ @Test
+ fun `WHEN removing action end THEN it will be forwarded to the edit toolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ val edit: EditToolbar = mock()
+ toolbar.edit = edit
+
+ val action = BrowserToolbar.Button(mock(), "QR code scanner") {
+ // Do nothing
+ }
+
+ toolbar.removeEditActionEnd(action)
+
+ verify(edit).removeEditActionEnd(action)
+ }
+
+ @Test
+ fun `WHEN hideMenuButton is sent to BrowserToolbar THEN it will be forwarded to the DisplayToolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ val display: DisplayToolbar = mock()
+ toolbar.display = display
+
+ toolbar.hideMenuButton()
+
+ verify(display).hideMenuButton()
+ }
+
+ @Test
+ fun `WHEN showMenuButton is sent to BrowserToolbar THEN it will be forwarded to the DisplayToolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ val display: DisplayToolbar = mock()
+ toolbar.display = display
+
+ toolbar.showMenuButton()
+
+ verify(display).showMenuButton()
+ }
+
+ @Test
+ fun `WHEN showPageActionSeparator is sent to BrowserToolbar THEN it will be forwarded to the DisplayToolbar and EditToolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ val display: DisplayToolbar = mock()
+ val edit: EditToolbar = mock()
+ toolbar.display = display
+ toolbar.edit = edit
+
+ toolbar.showPageActionSeparator()
+
+ verify(display).showPageActionSeparator()
+ verify(edit).showPageActionSeparator()
+ }
+
+ @Test
+ fun `WHEN hidePageActionSeparator is sent to BrowserToolbar THEN it will be forwarded to the DisplayToolbar and EditToolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ val display: DisplayToolbar = mock()
+ val edit: EditToolbar = mock()
+ toolbar.display = display
+ toolbar.edit = edit
+
+ toolbar.hidePageActionSeparator()
+
+ verify(display).hidePageActionSeparator()
+ verify(edit).hidePageActionSeparator()
+ }
+
+ @Test
+ fun `WHEN setDisplayHorizontalPadding is sent to BrowserToolbar THEN it will be forwarded to the DisplayToolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ val display: DisplayToolbar = mock()
+ toolbar.display = display
+ toolbar.edit = mock()
+
+ toolbar.setDisplayHorizontalPadding(123)
+ verify(display).setHorizontalPadding(123)
+
+ toolbar.setDisplayHorizontalPadding(0)
+ verify(display).setHorizontalPadding(0)
+ }
+
+ @Test
+ fun `cast to view`() {
+ // Given
+ val toolbar = BrowserToolbar(testContext)
+
+ // When
+ val view = toolbar.asView()
+
+ // Then
+ assertNotNull(view)
+ }
+
+ @Test
+ fun `URL update does not override search terms in edit mode`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ toolbar.display = spy(toolbar.display)
+ toolbar.edit = spy(toolbar.edit)
+
+ toolbar.setSearchTerms("mozilla android")
+ toolbar.url = "https://www.mozilla.com"
+ toolbar.editMode()
+ verify(toolbar.display).url = "https://www.mozilla.com"
+ verify(toolbar.edit).updateUrl("mozilla android", false)
+
+ toolbar.setSearchTerms("")
+ verify(toolbar.edit).updateUrl("", false)
+
+ toolbar.url = "https://www.mozilla.org"
+ toolbar.editMode()
+ verify(toolbar.display).url = "https://www.mozilla.org"
+ verify(toolbar.edit).updateUrl("https://www.mozilla.org", false)
+ }
+
+ @Test
+ fun `add navigation action will be forwarded to display toolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+ val display: DisplayToolbar = mock()
+ toolbar.display = display
+
+ val action = BrowserToolbar.Button(mock(), "Back") {
+ // Do nothing
+ }
+
+ toolbar.addNavigationAction(action)
+
+ verify(display).addNavigationAction(action)
+ }
+
+ @Test
+ fun `invalidate actions is forwarded to display toolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+ val display: DisplayToolbar = mock()
+ toolbar.display = display
+
+ verify(display, never()).invalidateActions()
+
+ toolbar.invalidateActions()
+
+ verify(display).invalidateActions()
+ }
+
+ @Test
+ fun `invalidate actions is forwarded to edit toolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+ val edit: EditToolbar = mock()
+ toolbar.edit = edit
+
+ verify(edit, never()).invalidateActions()
+
+ toolbar.invalidateActions()
+
+ verify(edit).invalidateActions()
+ }
+
+ @Test
+ fun `search terms (if set) are forwarded to edit toolbar instead of URL`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ toolbar.edit = spy(toolbar.edit)
+
+ toolbar.url = "https://www.mozilla.org"
+ toolbar.setSearchTerms("Mozilla Firefox")
+
+ verify(toolbar.edit, never()).updateUrl("https://www.mozilla.org")
+ verify(toolbar.edit, never()).updateUrl("Mozilla Firefox")
+
+ toolbar.editMode()
+
+ verify(toolbar.edit, never()).updateUrl("https://www.mozilla.org")
+ verify(toolbar.edit).updateUrl("Mozilla Firefox")
+ }
+
+ @Test
+ fun `search terms are forwarded to edit toolbar when it is active`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ toolbar.edit = spy(toolbar.edit)
+
+ toolbar.editMode()
+
+ toolbar.setSearchTerms("Mozilla Firefox")
+
+ verify(toolbar.edit).editSuggestion("Mozilla Firefox")
+ }
+
+ @Test
+ fun `editListener is set on edit`() {
+ val toolbar = BrowserToolbar(testContext)
+ assertNull(toolbar.edit.editListener)
+
+ val listener: Toolbar.OnEditListener = mock()
+ toolbar.setOnEditListener(listener)
+
+ assertEquals(listener, toolbar.edit.editListener)
+ }
+
+ @Test
+ fun `editListener is invoked when switching between modes`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ val listener: Toolbar.OnEditListener = mock()
+ toolbar.setOnEditListener(listener)
+
+ toolbar.editMode()
+
+ verify(listener).onStartEditing()
+ verifyNoMoreInteractions(listener)
+
+ toolbar.displayMode()
+
+ verify(listener).onStopEditing()
+ verifyNoMoreInteractions(listener)
+ }
+
+ @Test
+ fun `editListener is invoked when text changes`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ val listener: Toolbar.OnEditListener = mock()
+ toolbar.setOnEditListener(listener)
+
+ toolbar.edit.views.url.onAttachedToWindow()
+
+ toolbar.editMode()
+
+ toolbar.edit.views.url.setText("Hello")
+ toolbar.edit.views.url.setText("Hello World")
+
+ verify(listener).onStartEditing()
+ verify(listener).onTextChanged("Hello")
+ verify(listener).onTextChanged("Hello World")
+ }
+
+ @Test
+ fun `titleView visibility is based on being set`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ assertEquals(toolbar.display.views.origin.titleView.visibility, View.GONE)
+ toolbar.title = "Mozilla"
+ assertEquals(toolbar.display.views.origin.titleView.visibility, View.VISIBLE)
+ toolbar.title = ""
+ assertEquals(toolbar.display.views.origin.titleView.visibility, View.GONE)
+ }
+
+ @Test
+ fun `titleView text is set properly`() {
+ val toolbar = BrowserToolbar(testContext)
+
+ toolbar.title = "Mozilla"
+ assertEquals("Mozilla", toolbar.display.views.origin.titleView.text)
+ assertEquals("Mozilla", toolbar.title)
+ }
+
+ @Test
+ fun `titleView fading is set properly with non-null attrs`() {
+ val attributeSet: AttributeSet = Robolectric.buildAttributeSet().build()
+
+ val toolbar = BrowserToolbar(testContext, attributeSet)
+ val titleView = toolbar.display.views.origin.titleView
+ val edgeLength = testContext.resources.getDimensionPixelSize(R.dimen.mozac_browser_toolbar_url_fading_edge_size)
+
+ assertTrue(titleView.isHorizontalFadingEdgeEnabled)
+ assertEquals(edgeLength, titleView.horizontalFadingEdgeLength)
+ }
+
+ @Test
+ fun `Button constructor with drawable`() {
+ val buttonDefault = BrowserToolbar.Button(mock(), "imageDrawable") {}
+
+ assertEquals(true, buttonDefault.visible())
+ assertEquals(BrowserToolbar.DEFAULT_PADDING, buttonDefault.padding)
+ assertEquals("imageDrawable", buttonDefault.contentDescription)
+
+ val button = BrowserToolbar.Button(mock(), "imageDrawable", visible = { false }) {}
+
+ assertEquals(false, button.visible())
+ }
+
+ @Test
+ fun `ToggleButton constructor with drawable`() {
+ val buttonDefault =
+ BrowserToolbar.ToggleButton(mock(), mock(), "imageDrawable", "imageSelectedDrawable") {}
+
+ assertEquals(true, buttonDefault.visible())
+ assertEquals(BrowserToolbar.DEFAULT_PADDING, buttonDefault.padding)
+
+ val button = BrowserToolbar.ToggleButton(
+ mock(),
+ mock(),
+ "imageDrawable",
+ "imageSelectedDrawable",
+ visible = { false },
+ ) {}
+
+ assertEquals(false, button.visible())
+ }
+
+ @Test
+ fun `ReloadPageAction visibility changes update image`() {
+ val reloadImage: Drawable = mock()
+ val stopImage: Drawable = mock()
+ val view: ImageButton = mock()
+ var reloadPageAction = BrowserToolbar.TwoStateButton(reloadImage, "reload", stopImage, "stop") {}
+ assertFalse(reloadPageAction.enabled)
+ reloadPageAction.bind(view)
+ verify(view).setImageDrawable(reloadImage)
+ verify(view).contentDescription = "reload"
+
+ reloadPageAction = BrowserToolbar.TwoStateButton(reloadImage, "reload", stopImage, "stop", { false }) {}
+ reloadPageAction.bind(view)
+ verify(view).setImageDrawable(stopImage)
+ verify(view).contentDescription = "stop"
+ }
+
+ @Test
+ fun `siteSecure updates the display`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.display = spy(toolbar.display)
+ assertEquals(SiteSecurity.INSECURE, toolbar.siteSecure)
+
+ toolbar.siteSecure = SiteSecurity.SECURE
+
+ verify(toolbar.display).siteSecurity = SiteSecurity.SECURE
+ }
+
+ @Test
+ fun `siteTrackingProtection updates the display`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.display = spy(toolbar.display)
+ assertEquals(SiteTrackingProtection.OFF_GLOBALLY, toolbar.siteTrackingProtection)
+
+ toolbar.siteTrackingProtection = SiteTrackingProtection.ON_NO_TRACKERS_BLOCKED
+
+ verify(toolbar.display).setTrackingProtectionState(SiteTrackingProtection.ON_NO_TRACKERS_BLOCKED)
+
+ toolbar.siteTrackingProtection = SiteTrackingProtection.ON_NO_TRACKERS_BLOCKED
+ verifyNoMoreInteractions(toolbar.display)
+ }
+
+ @Test
+ fun `private flag sets IME_FLAG_NO_PERSONALIZED_LEARNING on url edit view`() {
+ val toolbar = BrowserToolbar(testContext)
+ val edit = toolbar.edit
+
+ // By default "private mode" is off.
+ assertEquals(
+ 0,
+ edit.views.url.imeOptions and
+ EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING,
+ )
+ assertEquals(false, toolbar.private)
+
+ // Turning on private mode sets flag
+ toolbar.private = true
+ assertNotEquals(
+ 0,
+ edit.views.url.imeOptions and
+ EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING,
+ )
+ assertTrue(toolbar.private)
+
+ // Turning private mode off again - should remove flag
+ toolbar.private = false
+ assertEquals(
+ 0,
+ edit.views.url.imeOptions and
+ EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING,
+ )
+ assertEquals(false, toolbar.private)
+ }
+
+ @Test
+ fun `setAutocompleteListener is forwarded to edit toolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.edit = mock()
+
+ val filter: suspend (String, AutocompleteDelegate) -> Unit = { _, _ ->
+ // Do nothing
+ }
+
+ toolbar.setAutocompleteListener(filter)
+
+ verify(toolbar.edit).setAutocompleteListener(filter)
+ }
+
+ @Test
+ fun `WHEN an attempt to refresh autocomplete suggestions is made THEN forward the call to edit toolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.edit = mock()
+ toolbar.setAutocompleteListener { _, _ -> }
+
+ toolbar.refreshAutocomplete()
+
+ verify(toolbar.edit).refreshAutocompleteSuggestion()
+ }
+
+ @Test
+ fun `onStop is forwarded to display toolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.display = mock()
+
+ toolbar.onStop()
+
+ verify(toolbar.display).onStop()
+ }
+
+ @Test
+ fun `dismiss menu is forwarded to display toolbar`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.display = mock()
+ val displayToolbarViews: DisplayToolbarViews = mock()
+ val menuButton: MenuButton = mock()
+
+ whenever(toolbar.display.views).thenReturn(displayToolbarViews)
+ whenever(displayToolbarViews.menu).thenReturn(menuButton)
+
+ toolbar.dismissMenu()
+ verify(menuButton).dismissMenu()
+ }
+
+ @Test
+ fun `enable scrolling is forwarded to the toolbar behavior`() {
+ // Seems like real instances are needed for things to be set properly
+ val toolbar = BrowserToolbar(testContext)
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val params = CoordinatorLayout.LayoutParams(10, 10).apply {
+ this.behavior = behavior
+ }
+ toolbar.layoutParams = params
+
+ toolbar.enableScrolling()
+
+ verify(behavior).enableScrolling()
+ }
+
+ @Test
+ fun `disable scrolling is forwarded to the toolbar behavior`() {
+ // Seems like real instances are needed for things to be set properly
+ val toolbar = BrowserToolbar(testContext)
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val params = CoordinatorLayout.LayoutParams(10, 10).apply {
+ this.behavior = behavior
+ }
+ toolbar.layoutParams = params
+
+ toolbar.disableScrolling()
+
+ verify(behavior).disableScrolling()
+ }
+
+ @Test
+ fun `expand is forwarded to the toolbar behavior`() {
+ // Seems like real instances are needed for things to be set properly
+ val toolbar = BrowserToolbar(testContext)
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val params = CoordinatorLayout.LayoutParams(10, 10).apply {
+ this.behavior = behavior
+ }
+ toolbar.layoutParams = params
+
+ toolbar.expand()
+
+ verify(behavior).forceExpand(toolbar)
+ }
+
+ @Test
+ fun `collapse is forwarded to the toolbar behavior`() {
+ // Seems like real instances are needed for things to be set properly
+ val toolbar = BrowserToolbar(testContext)
+ val behavior = spy(EngineViewScrollingBehavior(testContext, null, ViewPosition.BOTTOM))
+ val params = CoordinatorLayout.LayoutParams(10, 10).apply {
+ this.behavior = behavior
+ }
+ toolbar.layoutParams = params
+
+ toolbar.collapse()
+
+ verify(behavior).forceCollapse(toolbar)
+ }
+
+ @Test
+ fun `WHEN search terms changes THEN edit listener is notified`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.edit = spy(toolbar.edit)
+ toolbar.edit.editListener = mock()
+
+ toolbar.setSearchTerms("")
+ toolbar.editMode()
+
+ toolbar.setSearchTerms("test")
+ verify(toolbar.edit.editListener)?.onTextChanged("test")
+
+ toolbar.setSearchTerms("")
+ verify(toolbar.edit.editListener)?.onTextChanged("")
+ }
+
+ @Test
+ fun `WHEN switching to edit mode AND the cursor placement parameter is specified THEN call the correct method to place the cursor`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.edit = spy(toolbar.edit)
+
+ toolbar.editMode(Toolbar.CursorPlacement.ALL)
+
+ verify(toolbar.edit).selectAll()
+
+ toolbar.editMode(Toolbar.CursorPlacement.END)
+
+ verify(toolbar.edit).selectEnd()
+ }
+}
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/display/DisplayToolbarTest.kt b/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/display/DisplayToolbarTest.kt
new file mode 100644
index 0000000000..6a8ba9e478
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/display/DisplayToolbarTest.kt
@@ -0,0 +1,824 @@
+/* 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.browser.toolbar2.display
+
+import android.graphics.Color
+import android.os.Build
+import android.view.View
+import androidx.core.content.ContextCompat
+import androidx.core.view.isGone
+import androidx.core.view.isVisible
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuBuilder
+import mozilla.components.browser.menu.item.SimpleBrowserMenuItem
+import mozilla.components.browser.toolbar2.BrowserToolbar
+import mozilla.components.browser.toolbar2.R
+import mozilla.components.concept.menu.MenuButton
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.concept.toolbar.Toolbar.SiteSecurity
+import mozilla.components.concept.toolbar.Toolbar.SiteTrackingProtection
+import mozilla.components.support.base.Component
+import mozilla.components.support.base.facts.Action
+import mozilla.components.support.base.facts.processor.CollectionProcessor
+import mozilla.components.support.test.any
+import mozilla.components.support.test.eq
+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.assertNotEquals
+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.ArgumentMatchers.anyBoolean
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.reset
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+import org.robolectric.util.ReflectionHelpers
+import mozilla.components.ui.icons.R as iconsR
+
+@RunWith(AndroidJUnit4::class)
+class DisplayToolbarTest {
+ private fun createDisplayToolbar(): Pair {
+ val toolbar: BrowserToolbar = mock()
+ val displayToolbar = DisplayToolbar(
+ testContext,
+ toolbar,
+ View.inflate(testContext, R.layout.mozac_browser_toolbar_displaytoolbar, null),
+ )
+ return Pair(toolbar, displayToolbar)
+ }
+
+ @Test
+ fun `clicking on the URL switches the toolbar to editing mode`() {
+ val (toolbar, displayToolbar) = createDisplayToolbar()
+
+ val urlView = displayToolbar.views.origin.urlView
+ assertTrue(urlView.performClick())
+
+ verify(toolbar).editMode()
+ }
+
+ @Test
+ fun `progress is forwarded to progress bar`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ val progressView = displayToolbar.views.progress
+
+ displayToolbar.updateProgress(0)
+ assertEquals(0, progressView.progress)
+ assertEquals(View.GONE, progressView.visibility)
+
+ displayToolbar.updateProgress(10)
+ assertEquals(10, progressView.progress)
+ assertEquals(View.VISIBLE, progressView.visibility)
+
+ displayToolbar.updateProgress(50)
+ assertEquals(50, progressView.progress)
+ assertEquals(View.VISIBLE, progressView.visibility)
+
+ displayToolbar.updateProgress(75)
+ assertEquals(75, progressView.progress)
+ assertEquals(View.VISIBLE, progressView.visibility)
+
+ displayToolbar.updateProgress(100)
+ assertEquals(100, progressView.progress)
+ assertEquals(View.GONE, progressView.visibility)
+ }
+
+ @Test
+ fun `trackingProtectionViewColor will change the color of the trackingProtectionIconView`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ assertNull(displayToolbar.views.trackingProtectionIndicator.colorFilter)
+
+ displayToolbar.colors = displayToolbar.colors.copy(
+ trackingProtection = Color.BLUE,
+ )
+
+ assertNotNull(displayToolbar.views.trackingProtectionIndicator.colorFilter)
+ assertNotNull(displayToolbar.views.trackingProtectionIndicator.trackingProtectionTint)
+ }
+
+ @Test
+ fun `highlightView will change the color of the dot`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ assertNull(displayToolbar.views.highlight.colorFilter)
+
+ displayToolbar.colors = displayToolbar.colors.copy(highlight = Color.BLUE)
+
+ assertNotNull(displayToolbar.views.highlight.colorFilter)
+ assertNotNull(displayToolbar.views.highlight.highlightTint)
+ }
+
+ @Test
+ fun `tracking protection and separator views become visible when states ON OR ACTIVE are set to siteTrackingProtection`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ val trackingView = displayToolbar.views.trackingProtectionIndicator
+ val separatorView = displayToolbar.views.separator
+
+ assertTrue(trackingView.visibility == View.GONE)
+ assertTrue(separatorView.visibility == View.GONE)
+
+ displayToolbar.indicators = listOf(
+ DisplayToolbar.Indicators.SECURITY,
+ DisplayToolbar.Indicators.TRACKING_PROTECTION,
+ )
+ displayToolbar.url = "https://www.mozilla.org"
+ displayToolbar.displayIndicatorSeparator = true
+ displayToolbar.setTrackingProtectionState(SiteTrackingProtection.ON_NO_TRACKERS_BLOCKED)
+
+ assertTrue(trackingView.isVisible)
+ assertTrue(separatorView.isVisible)
+
+ displayToolbar.setTrackingProtectionState(SiteTrackingProtection.OFF_GLOBALLY)
+
+ assertTrue(trackingView.visibility == View.GONE)
+ assertTrue(separatorView.visibility == View.GONE)
+
+ displayToolbar.setTrackingProtectionState(SiteTrackingProtection.ON_TRACKERS_BLOCKED)
+
+ assertTrue(trackingView.isVisible)
+ assertTrue(separatorView.isVisible)
+ }
+
+ @Test
+ fun `setTrackingProtectionIcons will forward to TrackingProtectionIconView`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ displayToolbar.indicators = listOf(DisplayToolbar.Indicators.TRACKING_PROTECTION)
+ displayToolbar.setTrackingProtectionState(SiteTrackingProtection.ON_NO_TRACKERS_BLOCKED)
+
+ val oldTrackingProtectionIcon = displayToolbar.views.trackingProtectionIndicator.drawable
+ assertNotNull(oldTrackingProtectionIcon)
+
+ val drawable1 =
+ testContext.getDrawable(TrackingProtectionIconView.DEFAULT_ICON_ON_NO_TRACKERS_BLOCKED)!!
+ val drawable2 =
+ testContext.getDrawable(TrackingProtectionIconView.DEFAULT_ICON_ON_TRACKERS_BLOCKED)!!
+ val drawable3 =
+ testContext.getDrawable(TrackingProtectionIconView.DEFAULT_ICON_OFF_FOR_A_SITE)!!
+
+ displayToolbar.icons = displayToolbar.icons.copy(
+ trackingProtectionTrackersBlocked = drawable1,
+ trackingProtectionNothingBlocked = drawable2,
+ trackingProtectionException = drawable3,
+ )
+
+ assertNotEquals(
+ oldTrackingProtectionIcon,
+ displayToolbar.views.trackingProtectionIndicator.drawable,
+ )
+
+ assertEquals(drawable2, displayToolbar.views.trackingProtectionIndicator.drawable)
+
+ displayToolbar.setTrackingProtectionState(SiteTrackingProtection.ON_TRACKERS_BLOCKED)
+
+ assertNotEquals(
+ oldTrackingProtectionIcon,
+ displayToolbar.views.trackingProtectionIndicator.drawable,
+ )
+
+ assertEquals(
+ drawable1,
+ displayToolbar.views.trackingProtectionIndicator.drawable,
+ )
+ }
+
+ @Test
+ fun `setHighlight will forward to HighlightView`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ val oldPermissionIcon = displayToolbar.views.highlight.drawable
+ assertNotNull(oldPermissionIcon)
+
+ val drawable1 = testContext.getDrawable(HighlightView.DEFAULT_ICON)!!
+
+ displayToolbar.indicators = listOf(DisplayToolbar.Indicators.HIGHLIGHT)
+ displayToolbar.icons = displayToolbar.icons.copy(
+ highlight = drawable1,
+ )
+
+ assertNotEquals(
+ oldPermissionIcon,
+ displayToolbar.views.highlight.drawable,
+ )
+
+ displayToolbar.setHighlight(Toolbar.Highlight.PERMISSIONS_CHANGED)
+
+ assertNotEquals(
+ oldPermissionIcon,
+ displayToolbar.views.highlight.drawable,
+ )
+ }
+
+ @Test
+ fun `menu view is gone by default`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ val menuView = displayToolbar.views.menu
+
+ assertNotNull(menuView)
+ assertEquals(View.GONE, menuView.impl.visibility)
+ }
+
+ @Test
+ fun `menu view becomes visible once a menu builder is set`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ val menuView = displayToolbar.views.menu
+
+ assertNotNull(menuView)
+
+ assertEquals(View.GONE, menuView.impl.visibility)
+
+ displayToolbar.menuBuilder = BrowserMenuBuilder(emptyList())
+
+ assertEquals(View.VISIBLE, menuView.impl.visibility)
+
+ displayToolbar.menuBuilder = null
+
+ assertEquals(View.GONE, menuView.impl.visibility)
+ }
+
+ @Test
+ fun `no menu builder is set by default`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ assertNull(displayToolbar.menuBuilder)
+ }
+
+ @Test
+ fun `menu builder will be used to create and show menu when button is clicked`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+ val menuView = displayToolbar.views.menu
+
+ val menuBuilder = mock(BrowserMenuBuilder::class.java)
+ val menu = mock(BrowserMenu::class.java)
+ doReturn(menu).`when`(menuBuilder).build(testContext)
+
+ displayToolbar.menuBuilder = menuBuilder
+
+ verify(menuBuilder, never()).build(testContext)
+ verify(menu, never()).show(menuView.impl)
+
+ menuView.impl.performClick()
+
+ verify(menuBuilder).build(testContext)
+ verify(menu).show(eq(menuView.impl), any(), any(), anyBoolean(), any())
+ verify(menu, never()).invalidate()
+
+ displayToolbar.invalidateActions()
+
+ verify(menu).invalidate()
+ }
+
+ @Test
+ fun `browser action gets added as view to toolbar`() {
+ val contentDescription = "Mozilla"
+
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ assertEquals(0, displayToolbar.views.browserActions.childCount)
+
+ val action = BrowserToolbar.Button(mock(), contentDescription) {}
+ displayToolbar.addBrowserAction(action)
+
+ assertEquals(1, displayToolbar.views.browserActions.childCount)
+
+ val view = displayToolbar.views.browserActions.getChildAt(0)
+ assertEquals(contentDescription, view.contentDescription)
+ }
+
+ @Test
+ fun `clicking browser action view triggers listener of action`() {
+ var callbackExecuted = false
+
+ val action = BrowserToolbar.Button(mock(), "Button") {
+ callbackExecuted = true
+ }
+
+ val (_, displayToolbar) = createDisplayToolbar()
+ displayToolbar.addBrowserAction(action)
+
+ assertEquals(1, displayToolbar.views.browserActions.childCount)
+ val view = displayToolbar.views.browserActions.getChildAt(0)
+
+ assertNotNull(view)
+
+ assertFalse(callbackExecuted)
+
+ view?.performClick()
+
+ assertTrue(callbackExecuted)
+ }
+
+ @Test
+ fun `browser action can be removed`() {
+ val contentDescription = "to-be-removed"
+
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ val action = BrowserToolbar.Button(mock(), contentDescription) {}
+ // Removing action which was never added has no effect
+ displayToolbar.removeBrowserAction(action)
+
+ displayToolbar.addBrowserAction(action)
+ assertEquals(1, displayToolbar.views.browserActions.childCount)
+
+ displayToolbar.removeBrowserAction(action)
+ assertEquals(0, displayToolbar.views.browserActions.childCount)
+ }
+
+ @Test
+ fun `navigation action can be removed`() {
+ val contentDescription = "to-be-removed"
+
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ val action = BrowserToolbar.Button(mock(), contentDescription) {}
+ // Removing action which was never added has no effect
+ displayToolbar.removeNavigationAction(action)
+
+ displayToolbar.addNavigationAction(action)
+ assertEquals(1, displayToolbar.views.navigationActions.childCount)
+
+ displayToolbar.removeNavigationAction(action)
+ assertEquals(0, displayToolbar.views.navigationActions.childCount)
+ }
+
+ @Test
+ fun `page action can be removed`() {
+ val contentDescription = "to-be-removed"
+
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ val action = BrowserToolbar.Button(mock(), contentDescription) {}
+ // Removing action which was never added has no effect
+ displayToolbar.removePageAction(action)
+
+ displayToolbar.addPageAction(action)
+ assertEquals(1, displayToolbar.views.pageActions.childCount)
+
+ displayToolbar.removePageAction(action)
+ assertEquals(0, displayToolbar.views.pageActions.childCount)
+ }
+
+ @Test
+ fun `page actions will be added as view to the toolbar`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ assertEquals(0, displayToolbar.views.pageActions.childCount)
+
+ val action = BrowserToolbar.Button(mock(), "Reader Mode") {}
+ displayToolbar.addPageAction(action)
+
+ assertEquals(1, displayToolbar.views.pageActions.childCount)
+ val view = displayToolbar.views.pageActions.getChildAt(0)
+ assertEquals("Reader Mode", view.contentDescription)
+ }
+
+ @Test
+ fun `clicking a page action view will execute the listener of the action`() {
+ var listenerExecuted = false
+
+ val action = BrowserToolbar.Button(mock(), "Reload") {
+ listenerExecuted = true
+ }
+
+ val (_, displayToolbar) = createDisplayToolbar()
+ displayToolbar.addPageAction(action)
+
+ assertFalse(listenerExecuted)
+
+ assertEquals(1, displayToolbar.views.pageActions.childCount)
+ val view = displayToolbar.views.pageActions.getChildAt(0)
+
+ assertNotNull(view)
+ view!!.performClick()
+
+ assertTrue(listenerExecuted)
+ }
+
+ @Test
+ fun `navigation actions will be added as view to the toolbar`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ assertEquals(0, displayToolbar.views.navigationActions.childCount)
+
+ displayToolbar.addNavigationAction(BrowserToolbar.Button(mock(), "Back") {})
+ displayToolbar.addNavigationAction(BrowserToolbar.Button(mock(), "Forward") {})
+
+ assertEquals(2, displayToolbar.views.navigationActions.childCount)
+ }
+
+ @Test
+ fun `clicking on navigation action will execute listener of the action`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ var listenerExecuted = false
+ val action = BrowserToolbar.Button(mock(), "Back") {
+ listenerExecuted = true
+ }
+
+ displayToolbar.addNavigationAction(action)
+
+ assertFalse(listenerExecuted)
+
+ assertEquals(1, displayToolbar.views.navigationActions.childCount)
+ val view = displayToolbar.views.navigationActions.getChildAt(0)
+ view.performClick()
+
+ assertTrue(listenerExecuted)
+ }
+
+ @Test
+ fun `view of not visible navigation action gets removed after invalidating`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ var shouldActionBeDisplayed = true
+
+ val action = BrowserToolbar.Button(
+ mock(),
+ "Back",
+ visible = { shouldActionBeDisplayed },
+ ) { /* Do nothing */ }
+
+ displayToolbar.addNavigationAction(action)
+
+ assertEquals(1, displayToolbar.views.navigationActions.childCount)
+
+ shouldActionBeDisplayed = false
+ displayToolbar.invalidateActions()
+
+ assertEquals(0, displayToolbar.views.navigationActions.childCount)
+
+ shouldActionBeDisplayed = true
+ displayToolbar.invalidateActions()
+
+ assertEquals(1, displayToolbar.views.navigationActions.childCount)
+ }
+
+ @Test
+ fun `toolbar should call bind with view argument on action after invalidating`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ val action = spy(BrowserToolbar.Button(mock(), "Reload") {})
+
+ displayToolbar.addPageAction(action)
+
+ assertEquals(1, displayToolbar.views.pageActions.childCount)
+ val view = displayToolbar.views.pageActions.getChildAt(0)
+
+ verify(action, never()).bind(view!!)
+
+ displayToolbar.invalidateActions()
+
+ verify(action).bind(view)
+ }
+
+ @Test
+ fun `page action will not be added if visible lambda of action returns false`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ val visibleAction = BrowserToolbar.Button(mock(), "Reload") {}
+ val invisibleAction = BrowserToolbar.Button(
+ mock(),
+ "Reader Mode",
+ visible = { false },
+ ) {}
+
+ displayToolbar.addPageAction(visibleAction)
+ displayToolbar.addPageAction(invisibleAction)
+
+ assertEquals(1, displayToolbar.views.pageActions.childCount)
+
+ val view = displayToolbar.views.pageActions.getChildAt(0)
+ assertEquals("Reload", view.contentDescription)
+ }
+
+ @Test
+ fun `browser action will not be added if visible lambda of action returns false`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ val visibleAction = BrowserToolbar.Button(mock(), "Tabs") {}
+ val invisibleAction = BrowserToolbar.Button(
+ mock(),
+ "Settings",
+ visible = { false },
+ ) {}
+
+ displayToolbar.addBrowserAction(visibleAction)
+ displayToolbar.addBrowserAction(invisibleAction)
+
+ assertEquals(1, displayToolbar.views.browserActions.childCount)
+
+ val view = displayToolbar.views.browserActions.getChildAt(0)
+ assertEquals("Tabs", view.contentDescription)
+ }
+
+ @Test
+ fun `navigation action will not be added if visible lambda of action returns false`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ val visibleAction = BrowserToolbar.Button(mock(), "Forward") {}
+ val invisibleAction = BrowserToolbar.Button(
+ mock(),
+ "Back",
+ visible = { false },
+ ) {}
+
+ displayToolbar.addNavigationAction(visibleAction)
+ displayToolbar.addNavigationAction(invisibleAction)
+
+ assertEquals(1, displayToolbar.views.navigationActions.childCount)
+
+ val view = displayToolbar.views.navigationActions.getChildAt(0)
+ assertEquals("Forward", view.contentDescription)
+ }
+
+ @Test
+ fun `url background will be added and removed from display layout`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ assertNull(displayToolbar.views.background.drawable)
+
+ displayToolbar.setUrlBackground(
+ ContextCompat.getDrawable(
+ testContext,
+ iconsR.drawable.mozac_ic_broken_lock,
+ ),
+ )
+
+ assertNotNull(displayToolbar.views.background.drawable)
+
+ displayToolbar.setUrlBackground(null)
+
+ assertNull(displayToolbar.views.background.drawable)
+ }
+
+ @Test
+ fun `titleView does not display when there is no title text`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ assertTrue(displayToolbar.views.origin.titleView.isGone)
+
+ displayToolbar.title = "Hello World"
+
+ assertTrue(displayToolbar.views.origin.titleView.isVisible)
+ }
+
+ @Test
+ fun `toolbar only switches to editing mode if onUrlClicked returns true`() {
+ val (toolbar, displayToolbar) = createDisplayToolbar()
+
+ displayToolbar.views.origin.urlView.performClick()
+
+ verify(toolbar).editMode()
+
+ reset(toolbar)
+ displayToolbar.onUrlClicked = { false }
+ displayToolbar.views.origin.urlView.performClick()
+
+ verify(toolbar, never()).editMode()
+
+ reset(toolbar)
+ displayToolbar.onUrlClicked = { true }
+ displayToolbar.views.origin.urlView.performClick()
+
+ verify(toolbar).editMode()
+ }
+
+ @Test
+ fun `urlView delegates long click when set`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ var longUrlClicked = false
+
+ displayToolbar.setOnUrlLongClickListener {
+ longUrlClicked = true
+ false
+ }
+
+ assertFalse(longUrlClicked)
+ displayToolbar.views.origin.urlView.performLongClick()
+ assertTrue(longUrlClicked)
+ }
+
+ @Test
+ fun `urlView longClickListener can be unset`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ var longClicked = false
+ displayToolbar.setOnUrlLongClickListener {
+ longClicked = true
+ true
+ }
+
+ displayToolbar.views.origin.urlView.performLongClick()
+ assertTrue(longClicked)
+ longClicked = false
+
+ displayToolbar.setOnUrlLongClickListener(null)
+ displayToolbar.views.origin.urlView.performLongClick()
+
+ assertFalse(longClicked)
+ }
+
+ @Test
+ fun `iconView changes site secure state when site security changes`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+ assertEquals(SiteSecurity.INSECURE, displayToolbar.views.securityIndicator.siteSecurity)
+
+ displayToolbar.siteSecurity = SiteSecurity.SECURE
+
+ assertEquals(SiteSecurity.SECURE, displayToolbar.views.securityIndicator.siteSecurity)
+
+ displayToolbar.siteSecurity = SiteSecurity.INSECURE
+
+ assertEquals(SiteSecurity.INSECURE, displayToolbar.views.securityIndicator.siteSecurity)
+ }
+
+ @Test
+ fun `securityIconColor is set when securityIconColor changes`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ assertNull(displayToolbar.views.securityIndicator.colorFilter)
+
+ displayToolbar.colors = displayToolbar.colors.copy(
+ securityIconSecure = Color.BLUE,
+ securityIconInsecure = Color.BLUE,
+ )
+
+ assertNotNull(displayToolbar.views.securityIndicator.colorFilter)
+ }
+
+ @Test
+ fun `color filter is set with transparent when securityIconColor changes to transparent and api version is lower than 23`() {
+ ReflectionHelpers.setStaticField(Build.VERSION::class.java, "SDK_INT", 22)
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ assertNull(displayToolbar.views.securityIndicator.colorFilter)
+
+ displayToolbar.colors = displayToolbar.colors.copy(
+ securityIconSecure = Color.TRANSPARENT,
+ securityIconInsecure = Color.TRANSPARENT,
+ )
+
+ assertNotNull(displayToolbar.views.securityIndicator.colorFilter)
+ }
+
+ @Test
+ fun `color filter is cleared when securityIconColor changes to transparent and api version is bigger than 22`() {
+ ReflectionHelpers.setStaticField(Build.VERSION::class.java, "SDK_INT", 23)
+
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ assertNull(displayToolbar.views.securityIndicator.colorFilter)
+
+ displayToolbar.colors = displayToolbar.colors.copy(
+ securityIconSecure = Color.TRANSPARENT,
+ securityIconInsecure = Color.TRANSPARENT,
+ )
+
+ assertNull(displayToolbar.views.securityIndicator.colorFilter)
+ }
+
+ @Test
+ fun `clicking menu button emits facts with additional extras from builder set`() {
+ CollectionProcessor.withFactCollection { facts ->
+ val (_, displayToolbar) = createDisplayToolbar()
+ val menuView = displayToolbar.views.menu
+
+ val menuBuilder = BrowserMenuBuilder(
+ listOf(SimpleBrowserMenuItem("Mozilla")),
+ mapOf(
+ "customTab" to true,
+ "test" to "23",
+ ),
+ )
+ displayToolbar.menuBuilder = menuBuilder
+
+ assertEquals(0, facts.size)
+
+ menuView.impl.performClick()
+
+ assertEquals(1, facts.size)
+
+ val fact = facts[0]
+
+ assertEquals(Component.BROWSER_TOOLBAR, fact.component)
+ assertEquals(Action.CLICK, fact.action)
+ assertEquals("menu", fact.item)
+ assertNull(fact.value)
+
+ assertNotNull(fact.metadata)
+
+ val metadata = fact.metadata!!
+ assertEquals(2, metadata.size)
+ assertTrue(metadata.containsKey("customTab"))
+ assertTrue(metadata.containsKey("test"))
+ assertEquals(true, metadata["customTab"])
+ assertEquals("23", metadata["test"])
+ }
+ }
+
+ @Test
+ fun `clicking on site security indicator invokes listener`() {
+ var listenerInvoked = false
+
+ val (_, displayToolbar) = createDisplayToolbar()
+
+ assertNull(displayToolbar.views.securityIndicator.background)
+
+ displayToolbar.setOnSiteSecurityClickedListener {
+ listenerInvoked = true
+ }
+
+ assertNotNull(displayToolbar.views.securityIndicator.background)
+
+ displayToolbar.views.securityIndicator.performClick()
+
+ assertTrue(listenerInvoked)
+
+ listenerInvoked = false
+
+ displayToolbar.setOnSiteSecurityClickedListener { }
+
+ assertNotNull(displayToolbar.views.securityIndicator.background)
+
+ displayToolbar.views.securityIndicator.performClick()
+
+ assertFalse(listenerInvoked)
+
+ displayToolbar.setOnSiteSecurityClickedListener(null)
+
+ assertNull(displayToolbar.views.securityIndicator.background)
+ }
+
+ @Test
+ fun `Security icon has proper content description`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+ val siteSecurityIconView = displayToolbar.views.securityIndicator
+
+ assertNotNull(siteSecurityIconView.contentDescription)
+ assertEquals(
+ testContext.getString(R.string.mozac_browser_toolbar_content_description_site_info),
+ siteSecurityIconView.contentDescription,
+ )
+ }
+
+ @Test
+ fun `Backgrounding the app dismisses menu if already open`() {
+ var wasDismissed = false
+ val (_, displayToolbar) = createDisplayToolbar()
+ val menuView = displayToolbar.views.menu
+ menuView.impl.register(
+ object : MenuButton.Observer {
+ override fun onDismiss() {
+ wasDismissed = true
+ }
+ },
+ )
+ menuView.menuBuilder = BrowserMenuBuilder(emptyList())
+ menuView.impl.performClick()
+
+ displayToolbar.onStop()
+
+ assertTrue(wasDismissed)
+ }
+
+ @Test
+ fun `set a dismiss lambda on the menu button`() {
+ var wasDismissed = false
+ val (_, displayToolbar) = createDisplayToolbar()
+ displayToolbar.setMenuDismissAction { wasDismissed = true }
+ val menuView = displayToolbar.views.menu
+ menuView.menuBuilder = BrowserMenuBuilder(emptyList())
+ menuView.impl.performClick()
+
+ menuView.dismissMenu()
+ assertTrue(wasDismissed)
+ }
+
+ @Test
+ fun `url formatter used if provided`() {
+ val (_, displayToolbar) = createDisplayToolbar()
+ displayToolbar.url = "https://mozilla.org"
+ assertEquals(displayToolbar.url, displayToolbar.views.origin.url)
+
+ displayToolbar.urlFormatter = { it.replace("https://".toRegex(), "") }
+ displayToolbar.url = "https://mozilla.org"
+ assertEquals("mozilla.org", displayToolbar.views.origin.url)
+ }
+}
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/display/HighlightViewTest.kt b/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/display/HighlightViewTest.kt
new file mode 100644
index 0000000000..e339b98538
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/display/HighlightViewTest.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.browser.toolbar2.display
+
+import androidx.core.view.isVisible
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.toolbar2.R
+import mozilla.components.concept.toolbar.Toolbar.Highlight.NONE
+import mozilla.components.concept.toolbar.Toolbar.Highlight.PERMISSIONS_CHANGED
+import mozilla.components.support.test.robolectric.testContext
+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.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class HighlightViewTest {
+
+ @Test
+ fun `after setting tint, can get trackingProtectionTint`() {
+ val view = HighlightView(testContext)
+ view.setTint(android.R.color.black)
+ assertEquals(android.R.color.black, view.highlightTint)
+ }
+
+ @Test
+ fun `setting status will trigger an icon updated`() {
+ val view = HighlightView(testContext)
+
+ view.state = PERMISSIONS_CHANGED
+
+ assertEquals(PERMISSIONS_CHANGED, view.state)
+ assertTrue(view.isVisible)
+ assertNotNull(view.drawable)
+ assertEquals(
+ view.contentDescription,
+ testContext.getString(R.string.mozac_browser_toolbar_content_description_autoplay_blocked),
+ )
+
+ view.state = NONE
+
+ assertEquals(NONE, view.state)
+ assertNull(view.drawable)
+ assertFalse(view.isVisible)
+ assertNull(view.contentDescription)
+ }
+
+ @Test
+ fun `setIcons will trigger an icon updated`() {
+ val view = spy(HighlightView(testContext))
+
+ view.setIcon(
+ testContext.getDrawable(
+ TrackingProtectionIconView.DEFAULT_ICON_ON_NO_TRACKERS_BLOCKED,
+ )!!,
+ )
+
+ verify(view).updateIcon()
+ }
+}
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/display/MenuButtonTest.kt b/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/display/MenuButtonTest.kt
new file mode 100644
index 0000000000..ada49fd88b
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/display/MenuButtonTest.kt
@@ -0,0 +1,156 @@
+/* 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.browser.toolbar2.display
+
+import android.graphics.Color
+import android.view.View
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuBuilder
+import mozilla.components.browser.menu.BrowserMenuHighlight
+import mozilla.components.browser.menu.ext.getHighlight
+import mozilla.components.browser.menu.item.BrowserMenuHighlightableItem
+import mozilla.components.browser.menu.item.SimpleBrowserMenuItem
+import mozilla.components.concept.menu.MenuController
+import mozilla.components.concept.menu.candidate.DecorativeTextMenuCandidate
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+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.Before
+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.verify
+import org.mockito.Mockito.`when`
+import org.mockito.MockitoAnnotations
+
+@RunWith(AndroidJUnit4::class)
+class MenuButtonTest {
+ @Mock private lateinit var menuBuilder: BrowserMenuBuilder
+
+ @Mock private lateinit var menuController: MenuController
+
+ @Mock private lateinit var menu: BrowserMenu
+
+ @Mock private lateinit var menuButtonInternal: mozilla.components.browser.menu.view.MenuButton
+ private lateinit var menuButton: MenuButton
+
+ @Before
+ fun setup() {
+ MockitoAnnotations.openMocks(this)
+ `when`(menuBuilder.build(testContext)).thenReturn(menu)
+ `when`(menuButtonInternal.context).thenReturn(testContext)
+
+ menuButton = MenuButton(menuButtonInternal)
+ }
+
+ @Test
+ fun `menu button is visible only if menu builder attached`() {
+ verify(menuButtonInternal).visibility = View.GONE
+
+ `when`(menuButtonInternal.menuBuilder).thenReturn(mock())
+ assertTrue(menuButton.shouldBeVisible())
+
+ `when`(menuButtonInternal.menuBuilder).thenReturn(null)
+ assertFalse(menuButton.shouldBeVisible())
+ }
+
+ @Suppress("Deprecation")
+ @Test
+ fun `menu button sets onDismiss action`() {
+ val action = {}
+ menuButton.setMenuDismissAction(action)
+
+ verify(menuButtonInternal).onDismiss = action
+ }
+
+ @Test
+ fun `icon displays dot if low highlighted item is present in menu`() {
+ verify(menuButtonInternal, never()).invalidateBrowserMenu()
+ verify(menuButtonInternal, never()).setHighlight(any())
+
+ var isHighlighted = false
+ val highlight = BrowserMenuHighlight.LowPriority(Color.YELLOW)
+ val highlightMenuBuilder = spy(
+ BrowserMenuBuilder(
+ listOf(
+ BrowserMenuHighlightableItem(
+ label = "Test",
+ startImageResource = 0,
+ highlight = highlight,
+ isHighlighted = { isHighlighted },
+ ),
+ ),
+ ),
+ )
+ doReturn(menu).`when`(highlightMenuBuilder).build(testContext)
+
+ menuButton.menuBuilder = highlightMenuBuilder
+ `when`(menuButtonInternal.menuBuilder).thenReturn(highlightMenuBuilder)
+ menuButton.invalidateMenu()
+
+ verify(menuButtonInternal).setHighlight(null)
+
+ isHighlighted = true
+ menuButton.invalidateMenu()
+
+ assertEquals(highlight, highlightMenuBuilder.items.getHighlight())
+ verify(menuButtonInternal).setHighlight(highlight)
+ }
+
+ @Test
+ fun `invalidateMenu should invalidate the internal menu`() {
+ `when`(menuButtonInternal.menuController).thenReturn(null)
+ `when`(menuButtonInternal.menuBuilder).thenReturn(mock())
+ verify(menuButtonInternal, never()).invalidateBrowserMenu()
+
+ menuButton.invalidateMenu()
+
+ verify(menuButtonInternal).invalidateBrowserMenu()
+ }
+
+ @Test
+ fun `invalidateMenu should do nothing if using the menu controller`() {
+ `when`(menuButtonInternal.menuController).thenReturn(menuController)
+ `when`(menuButtonInternal.menuBuilder).thenReturn(null)
+ verify(menuButtonInternal, never()).invalidateBrowserMenu()
+
+ menuButton.invalidateMenu()
+
+ verify(menuButtonInternal, never()).invalidateBrowserMenu()
+ }
+
+ @Test
+ fun `invalidateMenu should automatically upgrade menu items if both builder and controller are present`() {
+ val onClick = {}
+ `when`(menuButtonInternal.menuController).thenReturn(menuController)
+ `when`(menuButtonInternal.menuBuilder).thenReturn(
+ BrowserMenuBuilder(
+ listOf(
+ SimpleBrowserMenuItem("Item 1", listener = onClick),
+ SimpleBrowserMenuItem("Item 2"),
+ ),
+ ),
+ )
+ verify(menuButtonInternal, never()).invalidateBrowserMenu()
+
+ menuButton.invalidateMenu()
+
+ verify(menuButtonInternal, never()).invalidateBrowserMenu()
+ verify(menuController).submitList(
+ listOf(
+ TextMenuCandidate("Item 1", onClick = onClick),
+ DecorativeTextMenuCandidate("Item 2"),
+ ),
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/display/TrackingProtectionIconViewTest.kt b/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/display/TrackingProtectionIconViewTest.kt
new file mode 100644
index 0000000000..1386c17e29
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/display/TrackingProtectionIconViewTest.kt
@@ -0,0 +1,43 @@
+/* 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.browser.toolbar2.display
+
+import android.graphics.PorterDuff
+import android.graphics.PorterDuffColorFilter
+import android.graphics.drawable.AnimatedVectorDrawable
+import android.graphics.drawable.Drawable
+import androidx.test.ext.junit.runners.AndroidJUnit4
+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.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class TrackingProtectionIconViewTest {
+
+ @Test
+ fun `After setting tint, can get trackingProtectionTint`() {
+ val view = TrackingProtectionIconView(testContext)
+ view.setTint(android.R.color.black)
+ assertEquals(android.R.color.black, view.trackingProtectionTint)
+ }
+
+ @Test
+ fun `colorFilter is cleared on animatable drawables`() {
+ val view = TrackingProtectionIconView(testContext)
+ view.trackingProtectionTint = android.R.color.black
+
+ val drawable = mock()
+ val animatedDrawable = mock()
+
+ view.setOrClearColorFilter(drawable)
+ assertEquals(PorterDuffColorFilter(android.R.color.black, PorterDuff.Mode.SRC_ATOP), view.colorFilter)
+
+ view.setOrClearColorFilter(animatedDrawable)
+ assertNotEquals(PorterDuffColorFilter(android.R.color.black, PorterDuff.Mode.SRC_ATOP), view.colorFilter)
+ }
+}
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/edit/EditToolbarTest.kt b/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/edit/EditToolbarTest.kt
new file mode 100644
index 0000000000..a371126a16
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/edit/EditToolbarTest.kt
@@ -0,0 +1,290 @@
+/* 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.browser.toolbar2.edit
+
+import android.view.KeyEvent
+import android.view.View
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import mozilla.components.browser.toolbar2.BrowserToolbar
+import mozilla.components.browser.toolbar2.R
+import mozilla.components.concept.toolbar.AutocompleteDelegate
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.support.base.Component
+import mozilla.components.support.base.facts.Action
+import mozilla.components.support.base.facts.processor.CollectionProcessor
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.ui.autocomplete.InlineAutocompleteEditText
+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.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.CountDownLatch
+
+@ExperimentalCoroutinesApi // for runTest
+@RunWith(AndroidJUnit4::class)
+class EditToolbarTest {
+ private fun createEditToolbar(): Pair {
+ val toolbar: BrowserToolbar = mock()
+ val displayToolbar = EditToolbar(
+ testContext,
+ toolbar,
+ View.inflate(testContext, R.layout.mozac_browser_toolbar_edittoolbar, null),
+ )
+ return Pair(toolbar, displayToolbar)
+ }
+
+ @Test
+ fun `entered text is forwarded to async autocomplete filter`() = runTest {
+ val toolbar = BrowserToolbar(testContext)
+
+ toolbar.edit.views.url.onAttachedToWindow()
+
+ val latch = CountDownLatch(1)
+ var invokedWithParams: List? = null
+ toolbar.setAutocompleteListener { p1, p2 ->
+ invokedWithParams = listOf(p1, p2)
+ latch.countDown()
+ }
+
+ toolbar.edit.views.url.setText("Hello")
+
+ // Autocomplete filter will be invoked on a worker thread.
+ // Serialize here for the sake of tests.
+ latch.await()
+
+ assertEquals("Hello", invokedWithParams!![0])
+ assertTrue(invokedWithParams!![1] is AutocompleteDelegate)
+ }
+
+ @Test
+ fun `GIVEN existing user input WHEN a call to refresh autocomplete suggestions is made THEN retart the autocomplete functionality with the current text`() {
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.edit.views.url.onAttachedToWindow()
+ // Fake existing user input.
+ toolbar.edit.views.url.setText("Test")
+ val latch = CountDownLatch(1)
+ var invokedWithParams: List? = null
+ // Only now enable the autocomplete functionality.
+ toolbar.setAutocompleteListener { p1, p2 ->
+ invokedWithParams = listOf(p1, p2)
+ latch.countDown()
+ }
+
+ toolbar.refreshAutocomplete()
+
+ // Autocomplete filter will be invoked on a worker thread.
+ // Serialize here for the sake of tests.
+ latch.await()
+ assertEquals("Test", invokedWithParams!![0])
+ assertTrue(invokedWithParams!![1] is AutocompleteDelegate)
+ }
+
+ @Test
+ fun `focus change is forwarded to listener`() {
+ var listenerInvoked = false
+ var value = false
+
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.edit.setOnEditFocusChangeListener { hasFocus ->
+ listenerInvoked = true
+ value = hasFocus
+ }
+
+ // Switch to editing mode and focus view.
+ toolbar.editMode()
+ toolbar.edit.views.url.requestFocus()
+
+ assertTrue(listenerInvoked)
+ assertTrue(value)
+
+ // Switch back to display mode
+ listenerInvoked = false
+ toolbar.displayMode()
+
+ assertTrue(listenerInvoked)
+ assertFalse(value)
+ }
+
+ @Test
+ fun `entering text emits facts`() {
+ CollectionProcessor.withFactCollection { facts ->
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.edit.views.url.onAttachedToWindow()
+
+ assertEquals(0, facts.size)
+
+ toolbar.edit.views.url.setText("https://www.mozilla.org")
+ toolbar.edit.views.url.dispatchKeyEvent(
+ KeyEvent(
+ System.currentTimeMillis(),
+ System.currentTimeMillis(),
+ KeyEvent.ACTION_DOWN,
+ KeyEvent.KEYCODE_ENTER,
+ 0,
+ ),
+ )
+
+ assertEquals(2, facts.size)
+
+ val factDetail = facts[0]
+ assertEquals(Component.UI_AUTOCOMPLETE, factDetail.component)
+ assertEquals(Action.IMPLEMENTATION_DETAIL, factDetail.action)
+ assertEquals("onTextChanged", factDetail.item)
+ assertEquals("InlineAutocompleteEditText", factDetail.value)
+
+ val fact = facts[1]
+ assertEquals(Component.BROWSER_TOOLBAR, fact.component)
+ assertEquals(Action.COMMIT, fact.action)
+ assertEquals("toolbar", fact.item)
+ assertNull(fact.value)
+
+ val metadata = fact.metadata
+ assertNotNull(metadata!!)
+ assertEquals(1, metadata.size)
+ assertTrue(metadata.contains("autocomplete"))
+ assertTrue(metadata["autocomplete"] is Boolean)
+ assertFalse(metadata["autocomplete"] as Boolean)
+ }
+ }
+
+ @Test
+ fun `entering text emits facts with autocomplete metadata`() {
+ CollectionProcessor.withFactCollection { facts ->
+ val toolbar = BrowserToolbar(testContext)
+ toolbar.edit.views.url.onAttachedToWindow()
+
+ assertEquals(0, facts.size)
+
+ toolbar.edit.views.url.setText("https://www.mozilla.org")
+
+ // Fake autocomplete
+ toolbar.edit.views.url.autocompleteResult = InlineAutocompleteEditText.AutocompleteResult(
+ text = "hello world",
+ source = "test-source",
+ totalItems = 100,
+ )
+
+ toolbar.edit.views.url.dispatchKeyEvent(
+ KeyEvent(
+ System.currentTimeMillis(),
+ System.currentTimeMillis(),
+ KeyEvent.ACTION_DOWN,
+ KeyEvent.KEYCODE_ENTER,
+ 0,
+ ),
+ )
+
+ assertEquals(2, facts.size)
+
+ val factDetail = facts[0]
+ assertEquals(Component.UI_AUTOCOMPLETE, factDetail.component)
+ assertEquals(Action.IMPLEMENTATION_DETAIL, factDetail.action)
+ assertEquals("onTextChanged", factDetail.item)
+ assertEquals("InlineAutocompleteEditText", factDetail.value)
+
+ val factCommit = facts[1]
+ assertEquals(Component.BROWSER_TOOLBAR, factCommit.component)
+ assertEquals(Action.COMMIT, factCommit.action)
+ assertEquals("toolbar", factCommit.item)
+ assertNull(factCommit.value)
+
+ val metadata = factCommit.metadata
+ assertNotNull(metadata!!)
+ assertEquals(2, metadata.size)
+
+ assertTrue(metadata.contains("autocomplete"))
+ assertTrue(metadata["autocomplete"] is Boolean)
+ assertTrue(metadata["autocomplete"] as Boolean)
+
+ assertTrue(metadata.contains("source"))
+ assertEquals("test-source", metadata["source"])
+ }
+ }
+
+ @Test
+ fun `clearView gone on init`() {
+ val (_, editToolbar) = createEditToolbar()
+ val clearView = editToolbar.views.clear
+ assertTrue(clearView.visibility == View.GONE)
+ }
+
+ @Test
+ fun `clearView visible on updateUrl`() {
+ val (_, editToolbar) = createEditToolbar()
+ val clearView = editToolbar.views.clear
+
+ editToolbar.updateUrl("TestUrl", false)
+ assertTrue(clearView.visibility == View.VISIBLE)
+ }
+
+ @Test
+ fun `WHEN shouldAppend is set to true updateUrl should append text`() {
+ val (_, editToolbar) = createEditToolbar()
+
+ // Initial state
+ editToolbar.updateUrl(url = "what ")
+
+ // Simulate text update with voice input
+ val actual = editToolbar.updateUrl(url = "is this", shouldAppend = true, shouldHighlight = true)
+ val expected = "what is this"
+
+ assertEquals(expected, actual)
+ assertEquals(expected, editToolbar.views.url.text.toString())
+ assertEquals(5, editToolbar.views.url.selectionStart)
+ assertEquals(12, editToolbar.views.url.selectionEnd)
+ }
+
+ @Test
+ fun `setIconClickListener sets a click listener on the icon view`() {
+ val (_, editToolbar) = createEditToolbar()
+ val iconView = editToolbar.views.icon
+ assertFalse(iconView.hasOnClickListeners())
+ editToolbar.setIconClickListener { /* noop */ }
+ assertTrue(iconView.hasOnClickListeners())
+ }
+
+ @Test
+ fun `clearView clears text in urlView`() {
+ val (_, editToolbar) = createEditToolbar()
+ val clearView = editToolbar.views.clear
+
+ editToolbar.views.url.setText("https://www.mozilla.org")
+ assertTrue(editToolbar.views.url.text.isNotBlank())
+
+ assertNotNull(clearView)
+ clearView.performClick()
+ assertTrue(editToolbar.views.url.text.isBlank())
+ }
+
+ @Test
+ fun `editSuggestion sets text in urlView`() {
+ val (_, editToolbar) = createEditToolbar()
+ val url = editToolbar.views.url
+
+ url.setText("https://www.mozilla.org")
+ assertEquals("https://www.mozilla.org", url.text.toString())
+
+ var callbackCalled = false
+
+ editToolbar.editListener = object : Toolbar.OnEditListener {
+ override fun onTextChanged(text: String) {
+ callbackCalled = true
+ }
+ }
+
+ editToolbar.editSuggestion("firefox")
+
+ assertEquals("firefox", url.text.toString())
+ assertTrue(callbackCalled)
+ assertEquals("firefox".length, url.selectionStart)
+ assertTrue(url.hasFocus())
+ }
+}
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/internal/ActionContainerTest.kt b/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/internal/ActionContainerTest.kt
new file mode 100644
index 0000000000..e79c804f9e
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/src/test/java/mozilla/components/browser/toolbar2/internal/ActionContainerTest.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.browser.toolbar2.internal
+
+import android.view.View
+import mozilla.components.browser.toolbar2.BrowserToolbar
+import mozilla.components.concept.toolbar.Toolbar
+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.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class ActionContainerTest {
+ private lateinit var actionContainer: ActionContainer
+ private lateinit var browserAction: Toolbar.Action
+
+ @Before
+ fun setUp() {
+ browserAction = BrowserToolbar.Button(
+ imageDrawable = mock(),
+ contentDescription = "Test",
+ visible = { true },
+ autoHide = { true },
+ weight = { 2 },
+ listener = mock(),
+ )
+ actionContainer = ActionContainer(testContext)
+ }
+
+ @Test
+ fun `GIVEN multiple actions with different weights WHEN calculateInsertionIndex is called THEN action is placed at right index`() {
+ actionContainer.addAction(
+ BrowserToolbar.Button(
+ imageDrawable = mock(),
+ contentDescription = "Share",
+ visible = { true },
+ weight = { 1 },
+ listener = mock(),
+ ),
+ )
+ actionContainer.addAction(
+ BrowserToolbar.Button(
+ imageDrawable = mock(),
+ contentDescription = "Reload",
+ visible = { true },
+ weight = { 3 },
+ listener = mock(),
+ ),
+ )
+ val newAction =
+ BrowserToolbar.Button(
+ imageDrawable = mock(),
+ contentDescription = "Translation",
+ visible = { true },
+ weight = { 2 },
+ listener = mock(),
+ )
+
+ val insertionIndex = actionContainer.calculateInsertionIndex(newAction)
+
+ assertEquals("The insertion index should be", 1, insertionIndex)
+ }
+
+ @Test
+ fun `WHEN addAction is called THEN child views are increased`() {
+ actionContainer.addAction(browserAction)
+
+ assertEquals(1, actionContainer.childCount)
+ }
+
+ @Test
+ fun `WHEN removeAction is called THEN child views are decreased`() {
+ actionContainer.addAction(browserAction)
+ actionContainer.removeAction(browserAction)
+
+ assertEquals(0, actionContainer.childCount)
+ }
+
+ @Test
+ fun `WHEN invalidateAction is called THEN action visibility is reconsidered`() {
+ val browserToolbarAction = BrowserToolbar.Button(
+ imageDrawable = mock(),
+ contentDescription = "Translation",
+ visible = { false },
+ weight = { 2 },
+ listener = mock(),
+ )
+ actionContainer.addAction(browserToolbarAction)
+ actionContainer.invalidateActions()
+
+ assertEquals(View.GONE, actionContainer.visibility)
+ }
+}
diff --git a/mobile/android/android-components/components/browser/toolbar2/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/browser/toolbar2/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..cf1c399ea8
--- /dev/null
+++ b/mobile/android/android-components/components/browser/toolbar2/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/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopupLayout.kt b/mobile/android/android-components/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopupLayout.kt
new file mode 100644
index 0000000000..4585c0303d
--- /dev/null
+++ b/mobile/android/android-components/components/compose/cfr/src/main/java/mozilla/components/compose/cfr/CFRPopupLayout.kt
@@ -0,0 +1,160 @@
+/* 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.compose.cfr
+
+import android.view.View
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.Button
+import androidx.compose.material.Text
+import androidx.compose.material.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+
+typealias DismissAction = () -> Unit
+
+/**
+ * A layout for displaying a [CFRPopup] anchored by [anchorContent].
+ *
+ * @param showCFR Whether to display the CFR.
+ * @param properties [CFRPopupProperties] allowing to customize the popup appearance and behavior.
+ * @param onCFRShown Invoked when the CFR is displayed.
+ * @param onDismiss Invoked when the CFR is dismissed. Returns true if the dismissal was
+ * explicit (e.g. clicked via the "X" button).
+ * @param text [Text] block containing the CFR's message.
+ * @param action Optional Composable displayed below [text]. Provides a [DismissAction] if the CFR needs
+ * to be dismissed after the action is invoked.
+ * @param anchorContent The Composable to anchor the CFR to.
+ */
+@Composable
+fun CFRPopupLayout(
+ showCFR: Boolean,
+ properties: CFRPopupProperties,
+ onCFRShown: () -> Unit,
+ onDismiss: (Boolean) -> Unit,
+ text: @Composable () -> Unit,
+ action: @Composable (dismissCFR: DismissAction) -> Unit = {},
+ anchorContent: @Composable () -> Unit,
+) {
+ var hasDismissedCFR by rememberSaveable { mutableStateOf(false) }
+
+ Box(
+ modifier = Modifier.height(intrinsicSize = IntrinsicSize.Min),
+ ) {
+ if (showCFR && !hasDismissedCFR) {
+ LaunchedEffect(Unit) {
+ onCFRShown()
+ }
+
+ var popup: CFRPopup? = null
+
+ val invokeDismiss: DismissAction = {
+ if (!hasDismissedCFR) {
+ popup?.dismiss()
+ }
+ popup = null
+ }
+
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = { context ->
+ View(context).also {
+ popup = CFRPopup(
+ anchor = it,
+ properties = properties,
+ onDismiss = { dismissFromButton ->
+ onDismiss(dismissFromButton)
+ hasDismissedCFR = true
+ },
+ text = text,
+ action = {
+ action(invokeDismiss)
+ },
+ )
+ }
+ },
+ onRelease = {
+ invokeDismiss()
+ },
+ update = {
+ popup?.dismiss()
+ popup?.show()
+ },
+ )
+ }
+
+ anchorContent()
+ }
+}
+
+/**
+ * This is to validate the sizing of the underlying AndroidView. The current implementation of CFRs
+ * via [CFRPopupFullscreenLayout] do not render in previews.
+ */
+@Preview
+@Composable
+private fun CFRPopupLayoutPreview() {
+ var cfrVisible by remember { mutableStateOf(true) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(color = Color.LightGray),
+ ) {
+ CFRPopupLayout(
+ showCFR = cfrVisible,
+ properties = CFRPopupProperties(),
+ onCFRShown = {},
+ onDismiss = { cfrVisible = false },
+ text = {
+ Text(text = "This is a CFR in Compose")
+ },
+ action = {
+ TextButton(onClick = { cfrVisible = false }) {
+ Text(text = "Dismiss")
+ }
+ },
+ ) {
+ Box(modifier = Modifier.size(300.dp)) {
+ Text(
+ text = "This is the thing the CFR is anchored to",
+ modifier = Modifier.align(alignment = Alignment.Center),
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(60.dp))
+
+ Box(modifier = Modifier.size(100.dp)) {
+ Text(
+ text = "This is just another element",
+ modifier = Modifier.align(alignment = Alignment.Center),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(60.dp))
+
+ Button(onClick = { cfrVisible = true }) {
+ Text(text = "Show CFR")
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationEngineState.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationEngineState.kt
index 1885c650a4..faa1513ec9 100644
--- a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationEngineState.kt
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationEngineState.kt
@@ -10,6 +10,7 @@ package mozilla.components.concept.engine.translate
* @property detectedLanguages Detected information about preferences and page information.
* @property error If an error state occurred or an error was reported.
* @property isEngineReady If the translation engine is primed for use or will need to be loaded.
+* @property hasVisibleChange If the browser has visibly started showing the translation.
* @property requestedTranslationPair The language pair to translate. Usually populated after first request.
*/
@@ -17,6 +18,7 @@ data class TranslationEngineState(
val detectedLanguages: DetectedLanguages? = null,
val error: String? = null,
val isEngineReady: Boolean? = false,
+ val hasVisibleChange: Boolean? = false,
val requestedTranslationPair: TranslationPair? = null,
)
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationOperation.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationOperation.kt
index 0f9b62029f..28dfd8b80c 100644
--- a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationOperation.kt
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/translate/TranslationOperation.kt
@@ -35,6 +35,13 @@ enum class TranslationOperation {
*/
FETCH_PAGE_SETTINGS,
+ /**
+ * Fetch the translations offer setting.
+ * Note: this request is also encompassed in [FETCH_PAGE_SETTINGS], but intended for checking
+ * fetching for global settings or when only this setting is needed.
+ */
+ FETCH_OFFER_SETTING,
+
/**
* Fetch the user preference on whether to offer, always translate, or never translate for
* all supported language settings.
diff --git a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtensionDelegate.kt b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtensionDelegate.kt
index fce18e3863..a2e8b18699 100644
--- a/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtensionDelegate.kt
+++ b/mobile/android/android-components/components/concept/engine/src/main/java/mozilla/components/concept/engine/webextension/WebExtensionDelegate.kt
@@ -48,6 +48,13 @@ interface WebExtensionDelegate {
*/
fun onReady(extension: WebExtension) = Unit
+ /**
+ * Invoked when optional permissions for a web extension have changed.
+ *
+ * @param extension The [WebExtension] for which permissions have changed.
+ */
+ fun onOptionalPermissionsChanged(extension: WebExtension) = Unit
+
/**
* Invoked when a web extension in private browsing allowed is set.
*
diff --git a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/example_mdn.json b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/example_mdn.json
index d08b78f9b7..4e6b1d6a98 100644
--- a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/example_mdn.json
+++ b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/example_mdn.json
@@ -5,33 +5,42 @@
"display": "standalone",
"background_color": "#ffffff",
"description": "A simply readable Hacker News app.",
- "icons": [{
- "src": "images/touch/homescreen48.png",
- "sizes": "48x48",
- "type": "image/png"
- }, {
- "src": "images/touch/homescreen72.png",
- "sizes": "72x72",
- "type": "image/png"
- }, {
- "src": "images/touch/homescreen96.png",
- "sizes": "96x96",
- "type": "image/png"
- }, {
- "src": "images/touch/homescreen144.png",
- "sizes": "144x144",
- "type": "image/png"
- }, {
- "src": "images/touch/homescreen168.png",
- "sizes": "168x168",
- "type": "image/png"
- }, {
- "src": "images/touch/homescreen192.png",
- "sizes": "192x192",
- "type": "image/png"
- }],
- "related_applications": [{
- "platform": "play",
- "url": "https://play.google.com/store/apps/details?id=cheeaun.hackerweb"
- }]
+ "icons": [
+ {
+ "src": "images/touch/homescreen48.png",
+ "sizes": "48x48",
+ "type": "image/png"
+ },
+ {
+ "src": "images/touch/homescreen72.png",
+ "sizes": "72x72",
+ "type": "image/png"
+ },
+ {
+ "src": "images/touch/homescreen96.png",
+ "sizes": "96x96",
+ "type": "image/png"
+ },
+ {
+ "src": "images/touch/homescreen144.png",
+ "sizes": "144x144",
+ "type": "image/png"
+ },
+ {
+ "src": "images/touch/homescreen168.png",
+ "sizes": "168x168",
+ "type": "image/png"
+ },
+ {
+ "src": "images/touch/homescreen192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ }
+ ],
+ "related_applications": [
+ {
+ "platform": "play",
+ "url": "https://play.google.com/store/apps/details?id=cheeaun.hackerweb"
+ }
+ ]
}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/spec_typical.json b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/spec_typical.json
index 3f180353eb..82aeb2c95f 100644
--- a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/spec_typical.json
+++ b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/spec_typical.json
@@ -4,17 +4,21 @@
"name": "Super Racer 3000",
"description": "The ultimate futuristic racing game from the future!",
"short_name": "Racer3K",
- "icons": [{
- "src": "icon/lowres.webp",
- "sizes": "64x64",
- "type": "image/webp"
- },{
- "src": "icon/lowres.png",
- "sizes": "64x64"
- }, {
- "src": "icon/hd_hi",
- "sizes": "128x128"
- }],
+ "icons": [
+ {
+ "src": "icon/lowres.webp",
+ "sizes": "64x64",
+ "type": "image/webp"
+ },
+ {
+ "src": "icon/lowres.png",
+ "sizes": "64x64"
+ },
+ {
+ "src": "icon/hd_hi",
+ "sizes": "128x128"
+ }
+ ],
"scope": "/racer/",
"start_url": "/racer/start.html",
"display": "fullscreen",
@@ -26,26 +30,34 @@
"scope": "/racer/",
"update_via_cache": "none"
},
- "screenshots": [{
- "src": "screenshots/in-game-1x.jpg",
- "sizes": "640x480",
- "type": "image/jpeg"
- },{
- "src": "screenshots/in-game-2x.jpg",
- "sizes": "1280x920",
- "type": "image/jpeg"
- }],
- "related_applications": [{
- "platform": "play",
- "url": "https://play.google.com/store/apps/details?id=com.example.app1",
- "id": "com.example.app1",
- "min_version": "2",
- "fingerprints": [{
- "type": "sha256_cert",
- "value": "92:5A:39:05:C5:B9:EA:BC:71:48:5F:F2"
- }]
- }, {
- "platform": "itunes",
- "url": "https://itunes.apple.com/app/example-app1/id123456789"
- }]
+ "screenshots": [
+ {
+ "src": "screenshots/in-game-1x.jpg",
+ "sizes": "640x480",
+ "type": "image/jpeg"
+ },
+ {
+ "src": "screenshots/in-game-2x.jpg",
+ "sizes": "1280x920",
+ "type": "image/jpeg"
+ }
+ ],
+ "related_applications": [
+ {
+ "platform": "play",
+ "url": "https://play.google.com/store/apps/details?id=com.example.app1",
+ "id": "com.example.app1",
+ "min_version": "2",
+ "fingerprints": [
+ {
+ "type": "sha256_cert",
+ "value": "92:5A:39:05:C5:B9:EA:BC:71:48:5F:F2"
+ }
+ ]
+ },
+ {
+ "platform": "itunes",
+ "url": "https://itunes.apple.com/app/example-app1/id123456789"
+ }
+ ]
}
diff --git a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/twitter_mobile.json b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/twitter_mobile.json
index 142ce0317e..2f661ffc34 100644
--- a/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/twitter_mobile.json
+++ b/mobile/android/android-components/components/concept/engine/src/test/resources/manifests/twitter_mobile.json
@@ -1 +1,28 @@
-{"background_color":"#ffffff","description":"It's what's happening. From breaking news and entertainment, sports and politics, to big events and everyday interests.","display":"standalone","gcm_sender_id":"49625052041","gcm_user_visible_only":true,"icons":[{"src":"https://abs.twimg.com/responsive-web/web/icon-default.604e2486a34a2f6e1.png","sizes":"192x192","type":"image/png"},{"src":"https://abs.twimg.com/responsive-web/web/icon-default.604e2486a34a2f6e1.png","sizes":"512x512","type":"image/png"}],"name":"Twitter","share_target":{"action":"compose/tweet","params":{"title":"title","text":"text","url":"url"}},"short_name":"Twitter","start_url":"/","theme_color":"#ffffff","scope":"/"}
+{
+ "background_color": "#ffffff",
+ "description": "It's what's happening. From breaking news and entertainment, sports and politics, to big events and everyday interests.",
+ "display": "standalone",
+ "gcm_sender_id": "49625052041",
+ "gcm_user_visible_only": true,
+ "icons": [
+ {
+ "src": "https://abs.twimg.com/responsive-web/web/icon-default.604e2486a34a2f6e1.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "https://abs.twimg.com/responsive-web/web/icon-default.604e2486a34a2f6e1.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ],
+ "name": "Twitter",
+ "share_target": {
+ "action": "compose/tweet",
+ "params": { "title": "title", "text": "text", "url": "url" }
+ },
+ "short_name": "Twitter",
+ "start_url": "/",
+ "theme_color": "#ffffff",
+ "scope": "/"
+}
diff --git a/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/AccountEvent.kt b/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/AccountEvent.kt
index fe46cc5b90..e7c0b1650e 100644
--- a/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/AccountEvent.kt
+++ b/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/AccountEvent.kt
@@ -46,6 +46,9 @@ sealed class AccountEvent {
sealed class DeviceCommandIncoming {
/** A command to open a list of tabs on the current device */
class TabReceived(val from: Device?, val entries: List) : DeviceCommandIncoming()
+
+ /** A command to close one or more tabs that are open on the current device */
+ class TabsClosed(val from: Device?, val urls: List) : DeviceCommandIncoming()
}
/**
@@ -54,6 +57,9 @@ sealed class DeviceCommandIncoming {
sealed class DeviceCommandOutgoing {
/** A command to open a tab on another device */
class SendTab(val title: String, val url: String) : DeviceCommandOutgoing()
+
+ /** A command to close one or more tabs that are open on another device */
+ class CloseTab(val urls: List) : DeviceCommandOutgoing()
}
/**
diff --git a/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/Devices.kt b/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/Devices.kt
index 94b022ce20..876ab86eb6 100644
--- a/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/Devices.kt
+++ b/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/Devices.kt
@@ -158,6 +158,7 @@ data class DeviceConfig(
*/
enum class DeviceCapability {
SEND_TAB,
+ CLOSE_TABS,
}
/**
diff --git a/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/OAuthAccount.kt b/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/OAuthAccount.kt
index 7737d4bc36..7ef087c86e 100644
--- a/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/OAuthAccount.kt
+++ b/mobile/android/android-components/components/concept/sync/src/main/java/mozilla/components/concept/sync/OAuthAccount.kt
@@ -39,6 +39,17 @@ data class MigratingAccountInfo(
val kXCS: String,
)
+/**
+ * User data provided by the web content as a means of delivering the session token to the
+ * application
+ */
+data class UserData(
+ val sessionToken: String,
+ val email: String,
+ val uid: String,
+ val verified: Boolean,
+)
+
/**
* Representing all the possible entry points into FxA
*
@@ -110,6 +121,14 @@ interface OAuthAccount : AutoCloseable {
*/
suspend fun getProfile(ignoreCache: Boolean = false): Profile?
+ /**
+ * Sets the user data given by the web content finishing the OAuth flow.
+ * This should only be used by user agents that need the session token
+ *
+ * @param userData: The user data provided by the web content, including the session token
+ */
+ suspend fun setUserData(userData: UserData)
+
/**
* Authenticates the current account using the [code] and [state] parameters obtained via the
* OAuth flow initiated by [beginOAuthFlow].
diff --git a/mobile/android/android-components/components/feature/accounts-push/build.gradle b/mobile/android/android-components/components/feature/accounts-push/build.gradle
index c87fa7582a..17bc5d8313 100644
--- a/mobile/android/android-components/components/feature/accounts-push/build.gradle
+++ b/mobile/android/android-components/components/feature/accounts-push/build.gradle
@@ -37,6 +37,7 @@ tasks.withType(KotlinCompile).configureEach {
}
dependencies {
+ implementation project(':browser-state')
implementation project(':service-firefox-accounts')
implementation project(':support-ktx')
implementation project(':support-base')
@@ -47,7 +48,9 @@ dependencies {
implementation ComponentsDependencies.androidx_lifecycle_process
implementation ComponentsDependencies.kotlin_coroutines
+ testImplementation project(':concept-engine')
testImplementation project(':support-test')
+ testImplementation project(':support-test-libstate')
testImplementation ComponentsDependencies.androidx_test_core
testImplementation ComponentsDependencies.androidx_test_junit
diff --git a/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/CloseTabsFeature.kt b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/CloseTabsFeature.kt
new file mode 100644
index 0000000000..8767064867
--- /dev/null
+++ b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/CloseTabsFeature.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.feature.accounts.push
+
+import androidx.annotation.VisibleForTesting
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ProcessLifecycleOwner
+import mozilla.components.browser.state.action.TabListAction
+import mozilla.components.browser.state.state.TabSessionState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.sync.AccountEvent
+import mozilla.components.concept.sync.AccountEventsObserver
+import mozilla.components.concept.sync.Device
+import mozilla.components.concept.sync.DeviceCommandIncoming
+import mozilla.components.concept.sync.DeviceConstellation
+import mozilla.components.service.fxa.manager.FxaAccountManager
+
+/**
+ * A feature for closing tabs on this device from other devices
+ * in the [DeviceConstellation].
+ *
+ * This feature receives commands to close tabs using the [FxaAccountManager].
+ *
+ * See [CloseTabsUseCases] for the ability to close tabs that are open on
+ * other devices from this device.
+ *
+ * @param browserStore The [BrowserStore] that holds the currently open tabs.
+ * @param accountManager The account manager.
+ * @param owner The Android lifecycle owner for the observers. Defaults to
+ * the [ProcessLifecycleOwner].
+ * @param autoPause Whether or not the observer should automatically be
+ * paused/resumed with the bound lifecycle.
+ * @param onTabsClosed The callback invoked when one or more tabs are closed.
+ */
+class CloseTabsFeature(
+ private val browserStore: BrowserStore,
+ private val accountManager: FxaAccountManager,
+ private val owner: LifecycleOwner = ProcessLifecycleOwner.get(),
+ private val autoPause: Boolean = false,
+ onTabsClosed: (Device?, List) -> Unit,
+) {
+ @VisibleForTesting internal val observer = TabsClosedEventsObserver { device, urls ->
+ val tabsToRemove = getTabsToRemove(urls)
+ if (tabsToRemove.isNotEmpty()) {
+ browserStore.dispatch(TabListAction.RemoveTabsAction(tabsToRemove.map { it.id }))
+ onTabsClosed(device, tabsToRemove.map { it.content.url })
+ }
+ }
+
+ /**
+ * Begins observing the [accountManager] for "tabs closed" events.
+ */
+ fun observe() {
+ accountManager.registerForAccountEvents(observer, owner, autoPause)
+ }
+
+ private fun getTabsToRemove(remotelyClosedUrls: List): List {
+ // The user might have the same URL open in multiple tabs on this device, and might want
+ // to remotely close some or all of those tabs. Synced tabs don't carry enough
+ // information to know which duplicates the user meant to close, so we use a heuristic:
+ // if a URL appears N times in the remotely closed URLs list, we'll close up to
+ // N instances of that URL.
+ val countsByUrl = remotelyClosedUrls.groupingBy { it }.eachCount()
+ return browserStore.state.tabs
+ .groupBy { it.content.url }
+ .asSequence()
+ .mapNotNull { (url, tabs) ->
+ countsByUrl[url]?.let { count -> tabs.take(count) }
+ }
+ .flatten()
+ .toList()
+ }
+}
+
+internal class TabsClosedEventsObserver(
+ internal val onTabsClosed: (Device?, List) -> Unit,
+) : AccountEventsObserver {
+ override fun onEvents(events: List) {
+ // Group multiple commands from the same device, so that we can close
+ // more tabs at once.
+ events.asSequence()
+ .filterIsInstance()
+ .map { it.command }
+ .filterIsInstance()
+ .groupingBy { it.from }
+ .fold(emptyList()) { urls, command -> urls + command.urls }
+ .forEach { (device, urls) ->
+ onTabsClosed(device, urls)
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/CloseTabsUseCases.kt b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/CloseTabsUseCases.kt
new file mode 100644
index 0000000000..845ae27a98
--- /dev/null
+++ b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/CloseTabsUseCases.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.feature.accounts.push
+
+import androidx.annotation.VisibleForTesting
+import androidx.annotation.WorkerThread
+import mozilla.components.concept.sync.Device
+import mozilla.components.concept.sync.DeviceCapability
+import mozilla.components.concept.sync.DeviceCommandOutgoing
+import mozilla.components.concept.sync.DeviceConstellation
+import mozilla.components.service.fxa.manager.FxaAccountManager
+
+/**
+ * Use cases for closing tabs that are open on other devices in the [DeviceConstellation].
+ *
+ * The use cases send commands to close tabs using the [FxaAccountManager].
+ *
+ * See [CloseTabsFeature] for the ability to close tabs on this device from
+ * other devices.
+ *
+ * @param accountManager The account manager.
+ */
+class CloseTabsUseCases(private val accountManager: FxaAccountManager) {
+ /**
+ * Closes a tab that's currently open on another device.
+ *
+ * @param deviceId The ID of the device on which the tab is currently open.
+ * @param url The URL of the tab to close.
+ * @return Whether the command to close the tab was sent to the device.
+ */
+ @WorkerThread
+ suspend fun close(deviceId: String, url: String): Boolean {
+ filterCloseTabsDevices(accountManager) { constellation, devices ->
+ val device = devices.firstOrNull { it.id == deviceId }
+ device?.let {
+ return constellation.sendCommandToDevice(
+ device.id,
+ DeviceCommandOutgoing.CloseTab(listOf(url)),
+ )
+ }
+ }
+
+ return false
+ }
+}
+
+@VisibleForTesting
+internal inline fun filterCloseTabsDevices(
+ accountManager: FxaAccountManager,
+ block: (DeviceConstellation, Collection) -> Unit,
+) {
+ val constellation = accountManager.authenticatedAccount()?.deviceConstellation() ?: return
+
+ constellation.state()?.let { state ->
+ state.otherDevices.filter {
+ it.capabilities.contains(DeviceCapability.CLOSE_TABS)
+ }.let { devices ->
+ block(constellation, devices)
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/SendTabFeature.kt b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/SendTabFeature.kt
index 4f049a790b..931dcf59a6 100644
--- a/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/SendTabFeature.kt
+++ b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/SendTabFeature.kt
@@ -24,7 +24,7 @@ import mozilla.components.support.base.log.logger.Logger
*
* See [SendTabUseCases] for the ability to send tabs to other devices.
*
- * @param accountManager Firefox account manager.
+ * @param accountManager Account manager.
* @param owner Android lifecycle owner for the observers. Defaults to the [ProcessLifecycleOwner]
* so that we can always observe events throughout the application lifecycle.
* @param autoPause whether or not the observer should automatically be
@@ -38,7 +38,7 @@ class SendTabFeature(
onTabsReceived: (Device?, List) -> Unit,
) {
init {
- val observer = EventsObserver(onTabsReceived)
+ val observer = TabReceivedEventsObserver(onTabsReceived)
// Observe the account for all account events, although we'll ignore
// non send-tab command events.
@@ -46,10 +46,10 @@ class SendTabFeature(
}
}
-internal class EventsObserver(
+internal class TabReceivedEventsObserver(
private val onTabsReceived: (Device?, List) -> Unit,
) : AccountEventsObserver {
- private val logger = Logger("EventsObserver")
+ private val logger = Logger("TabReceivedEventsObserver")
override fun onEvents(events: List) {
events.asSequence()
diff --git a/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/CloseTabsFeatureTest.kt b/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/CloseTabsFeatureTest.kt
new file mode 100644
index 0000000000..7b18681dce
--- /dev/null
+++ b/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/CloseTabsFeatureTest.kt
@@ -0,0 +1,136 @@
+/* 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.feature.accounts.push
+
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.createTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.sync.Device
+import mozilla.components.concept.sync.DeviceCapability
+import mozilla.components.concept.sync.DeviceType
+import mozilla.components.support.test.any
+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.rule.MainCoroutineRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+
+class CloseTabsFeatureTest {
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private val device123 = Device(
+ id = "123",
+ displayName = "Charcoal",
+ deviceType = DeviceType.DESKTOP,
+ isCurrentDevice = false,
+ lastAccessTime = null,
+ capabilities = listOf(DeviceCapability.CLOSE_TABS),
+ subscriptionExpired = true,
+ subscription = null,
+ )
+
+ @Test
+ fun `GIVEN a notification to close multiple URLs WHEN all URLs are open in tabs THEN all tabs are closed and the callback is invoked`() {
+ val urls = listOf(
+ "https://mozilla.org",
+ "https://getfirefox.com",
+ "https://example.org",
+ "https://getthunderbird.com",
+ )
+ val browserStore = BrowserStore(
+ BrowserState(
+ tabs = urls.map { createTab(it) },
+ ),
+ )
+ val callback: (Device?, List) -> Unit = mock()
+ val feature = CloseTabsFeature(
+ browserStore,
+ accountManager = mock(),
+ owner = mock(),
+ onTabsClosed = callback,
+ )
+
+ feature.observer.onTabsClosed(device123, urls)
+
+ browserStore.waitUntilIdle()
+
+ assertTrue(browserStore.state.tabs.isEmpty())
+ verify(callback).invoke(eq(device123), eq(urls))
+ }
+
+ @Test
+ fun `GIVEN a notification to close a URL WHEN the URL is not open in a tab THEN the callback is not invoked`() {
+ val browserStore = BrowserStore()
+ val callback: (Device?, List) -> Unit = mock()
+ val feature = CloseTabsFeature(
+ browserStore,
+ accountManager = mock(),
+ owner = mock(),
+ onTabsClosed = callback,
+ )
+
+ feature.observer.onTabsClosed(device123, listOf("https://mozilla.org"))
+
+ browserStore.waitUntilIdle()
+
+ verify(callback, never()).invoke(any(), any())
+ }
+
+ @Test
+ fun `GIVEN a notification to close duplicate URLs WHEN the duplicate URLs are open in tabs THEN the number of tabs closed matches the number of URLs and the callback is invoked`() {
+ val browserStore = BrowserStore(
+ BrowserState(
+ tabs = listOf(
+ createTab("https://mozilla.org", id = "1"),
+ createTab("https://mozilla.org", id = "2"),
+ createTab("https://getfirefox.com", id = "3"),
+ createTab("https://getfirefox.com", id = "4"),
+ createTab("https://getfirefox.com", id = "5"),
+ createTab("https://getthunderbird.com", id = "6"),
+ createTab("https://example.org", id = "7"),
+ ),
+ ),
+ )
+ val callback: (Device?, List) -> Unit = mock()
+ val feature = CloseTabsFeature(
+ browserStore,
+ accountManager = mock(),
+ owner = mock(),
+ onTabsClosed = callback,
+ )
+
+ feature.observer.onTabsClosed(
+ device123,
+ listOf(
+ "https://mozilla.org",
+ "https://getfirefox.com",
+ "https://getfirefox.com",
+ "https://example.org",
+ "https://example.org",
+ ),
+ )
+
+ browserStore.waitUntilIdle()
+
+ assertEquals(listOf("2", "5", "6"), browserStore.state.tabs.map { it.id })
+ verify(callback).invoke(
+ eq(device123),
+ eq(
+ listOf(
+ "https://mozilla.org",
+ "https://getfirefox.com",
+ "https://getfirefox.com",
+ "https://example.org",
+ ),
+ ),
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/CloseTabsUseCasesTest.kt b/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/CloseTabsUseCasesTest.kt
new file mode 100644
index 0000000000..4aae8c84f6
--- /dev/null
+++ b/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/CloseTabsUseCasesTest.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.feature.accounts.push
+
+import mozilla.components.concept.sync.ConstellationState
+import mozilla.components.concept.sync.Device
+import mozilla.components.concept.sync.DeviceCapability
+import mozilla.components.concept.sync.DeviceConstellation
+import mozilla.components.concept.sync.DeviceType
+import mozilla.components.concept.sync.OAuthAccount
+import mozilla.components.service.fxa.manager.FxaAccountManager
+import mozilla.components.support.test.any
+import mozilla.components.support.test.mock
+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.mockito.Mockito.never
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
+
+class CloseTabsUseCasesTest {
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private val device123 = Device(
+ id = "123",
+ displayName = "Charcoal",
+ deviceType = DeviceType.DESKTOP,
+ isCurrentDevice = false,
+ lastAccessTime = null,
+ capabilities = listOf(DeviceCapability.CLOSE_TABS),
+ subscriptionExpired = true,
+ subscription = null,
+ )
+
+ private val device1234 = Device(
+ id = "1234",
+ displayName = "Ruby",
+ deviceType = DeviceType.DESKTOP,
+ isCurrentDevice = false,
+ lastAccessTime = null,
+ capabilities = emptyList(),
+ subscriptionExpired = true,
+ subscription = null,
+ )
+
+ private val manager: FxaAccountManager = mock()
+ private val account: OAuthAccount = mock()
+ private val constellation: DeviceConstellation = mock()
+ private val state: ConstellationState = mock()
+
+ @Before
+ fun setUp() {
+ `when`(manager.authenticatedAccount()).thenReturn(account)
+ `when`(account.deviceConstellation()).thenReturn(constellation)
+ `when`(constellation.state()).thenReturn(state)
+ }
+
+ @Test
+ fun `GIVEN a list of devices WHEN one device supports the close tabs command THEN filtering returns that device`() {
+ val deviceIds = mutableListOf()
+ `when`(state.otherDevices).thenReturn(listOf(device123, device1234))
+ filterCloseTabsDevices(manager) { _, devices ->
+ deviceIds.addAll(devices.map { it.id })
+ }
+
+ assertEquals(listOf("123"), deviceIds)
+ }
+
+ @Test
+ fun `GIVEN a constellation with one capable device WHEN sending a close tabs command to that device THEN the command is sent`() = runTestOnMain {
+ val useCases = CloseTabsUseCases(manager)
+
+ `when`(state.otherDevices).thenReturn(listOf(device123))
+ `when`(constellation.sendCommandToDevice(any(), any()))
+ .thenReturn(true)
+
+ useCases.close("123", "http://example.com")
+
+ verify(constellation).sendCommandToDevice(any(), any())
+ }
+
+ @Test
+ fun `GIVEN a constellation with one incapable device WHEN sending a close tabs command to that device THEN the command is not sent`() = runTestOnMain {
+ val useCases = CloseTabsUseCases(manager)
+
+ `when`(state.otherDevices).thenReturn(listOf(device1234))
+ `when`(constellation.sendCommandToDevice(any(), any()))
+ .thenReturn(false)
+
+ useCases.close("1234", "http://example.com")
+
+ verify(constellation, never()).sendCommandToDevice(any(), any())
+ }
+}
diff --git a/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/EventsObserverTest.kt b/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/EventsObserverTest.kt
deleted file mode 100644
index 6de8ff42f6..0000000000
--- a/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/EventsObserverTest.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-/* 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.feature.accounts.push
-
-import mozilla.components.concept.sync.AccountEvent
-import mozilla.components.concept.sync.Device
-import mozilla.components.concept.sync.DeviceCommandIncoming
-import mozilla.components.concept.sync.TabData
-import mozilla.components.support.test.any
-import mozilla.components.support.test.eq
-import mozilla.components.support.test.mock
-import org.junit.Test
-import org.mockito.Mockito.times
-import org.mockito.Mockito.verify
-
-class EventsObserverTest {
- @Test
- fun `events are delivered successfully`() {
- val callback: (Device?, List) -> Unit = mock()
- val observer = EventsObserver(callback)
- val events = listOf(AccountEvent.DeviceCommandIncoming(command = DeviceCommandIncoming.TabReceived(mock(), mock())))
-
- observer.onEvents(events)
-
- verify(callback).invoke(any(), any())
-
- observer.onEvents(listOf(AccountEvent.DeviceCommandIncoming(command = DeviceCommandIncoming.TabReceived(null, mock()))))
-
- verify(callback).invoke(eq(null), any())
- }
-
- @Test
- fun `only TabReceived commands are delivered`() {
- val callback: (Device?, List) -> Unit = mock()
- val observer = EventsObserver(callback)
- val events = listOf(
- AccountEvent.ProfileUpdated,
- AccountEvent.DeviceCommandIncoming(command = DeviceCommandIncoming.TabReceived(mock(), mock())),
- AccountEvent.DeviceCommandIncoming(command = DeviceCommandIncoming.TabReceived(mock(), mock())),
- )
-
- observer.onEvents(events)
-
- verify(callback, times(2)).invoke(any(), any())
- }
-}
diff --git a/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/TabReceivedEventsObserverTest.kt b/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/TabReceivedEventsObserverTest.kt
new file mode 100644
index 0000000000..1b8128d4ba
--- /dev/null
+++ b/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/TabReceivedEventsObserverTest.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.feature.accounts.push
+
+import mozilla.components.concept.sync.AccountEvent
+import mozilla.components.concept.sync.Device
+import mozilla.components.concept.sync.DeviceCommandIncoming
+import mozilla.components.concept.sync.TabData
+import mozilla.components.support.test.any
+import mozilla.components.support.test.eq
+import mozilla.components.support.test.mock
+import org.junit.Test
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+
+class TabReceivedEventsObserverTest {
+ @Test
+ fun `events are delivered successfully`() {
+ val callback: (Device?, List) -> Unit = mock()
+ val observer = TabReceivedEventsObserver(callback)
+ val events = listOf(AccountEvent.DeviceCommandIncoming(command = DeviceCommandIncoming.TabReceived(mock(), mock())))
+
+ observer.onEvents(events)
+
+ verify(callback).invoke(any(), any())
+
+ observer.onEvents(listOf(AccountEvent.DeviceCommandIncoming(command = DeviceCommandIncoming.TabReceived(null, mock()))))
+
+ verify(callback).invoke(eq(null), any())
+ }
+
+ @Test
+ fun `only TabReceived commands are delivered`() {
+ val callback: (Device?, List) -> Unit = mock()
+ val observer = TabReceivedEventsObserver(callback)
+ val events = listOf(
+ AccountEvent.ProfileUpdated,
+ AccountEvent.DeviceCommandIncoming(command = DeviceCommandIncoming.TabReceived(mock(), mock())),
+ AccountEvent.DeviceCommandIncoming(command = DeviceCommandIncoming.TabReceived(mock(), mock())),
+ )
+
+ observer.onEvents(events)
+
+ verify(callback, times(2)).invoke(any(), any())
+ }
+}
diff --git a/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/TabsClosedEventsObserverTest.kt b/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/TabsClosedEventsObserverTest.kt
new file mode 100644
index 0000000000..8d51d0b812
--- /dev/null
+++ b/mobile/android/android-components/components/feature/accounts-push/src/test/java/mozilla/components/feature/accounts/push/TabsClosedEventsObserverTest.kt
@@ -0,0 +1,183 @@
+/* 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.feature.accounts.push
+
+import mozilla.components.concept.sync.AccountEvent
+import mozilla.components.concept.sync.Device
+import mozilla.components.concept.sync.DeviceCapability
+import mozilla.components.concept.sync.DeviceCommandIncoming
+import mozilla.components.concept.sync.DeviceType
+import mozilla.components.support.test.any
+import mozilla.components.support.test.eq
+import mozilla.components.support.test.mock
+import org.junit.Test
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+
+class TabsClosedEventsObserverTest {
+ private val device123 = Device(
+ id = "123",
+ displayName = "Charcoal",
+ deviceType = DeviceType.DESKTOP,
+ isCurrentDevice = false,
+ lastAccessTime = null,
+ capabilities = listOf(DeviceCapability.CLOSE_TABS),
+ subscriptionExpired = true,
+ subscription = null,
+ )
+
+ private val device1234 = Device(
+ id = "1234",
+ displayName = "Emerald",
+ deviceType = DeviceType.MOBILE,
+ isCurrentDevice = false,
+ lastAccessTime = null,
+ capabilities = listOf(DeviceCapability.CLOSE_TABS),
+ subscriptionExpired = true,
+ subscription = null,
+ )
+
+ private val device12345 = Device(
+ id = "12345",
+ displayName = "Sapphire",
+ deviceType = DeviceType.MOBILE,
+ isCurrentDevice = false,
+ lastAccessTime = null,
+ capabilities = listOf(DeviceCapability.CLOSE_TABS),
+ subscriptionExpired = true,
+ subscription = null,
+ )
+
+ @Test
+ fun `GIVEN a tabs closed command WHEN the observer is notified THEN the callback is invoked`() {
+ val callback: (Device?, List) -> Unit = mock()
+ val observer = TabsClosedEventsObserver(callback)
+ val events = listOf(
+ AccountEvent.DeviceCommandIncoming(
+ command = DeviceCommandIncoming.TabsClosed(
+ null,
+ listOf("https://mozilla.org"),
+ ),
+ ),
+ )
+
+ observer.onEvents(events)
+
+ verify(callback).invoke(eq(null), eq(listOf("https://mozilla.org")))
+ }
+
+ @Test
+ fun `GIVEN a tabs closed command from a device WHEN the observer is notified THEN the callback is invoked`() {
+ val callback: (Device?, List) -> Unit = mock()
+ val observer = TabsClosedEventsObserver(callback)
+ val events = listOf(
+ AccountEvent.DeviceCommandIncoming(
+ command = DeviceCommandIncoming.TabsClosed(
+ device123,
+ listOf("https://mozilla.org"),
+ ),
+ ),
+ )
+
+ observer.onEvents(events)
+
+ verify(callback).invoke(eq(device123), eq(listOf("https://mozilla.org")))
+ }
+
+ @Test
+ fun `GIVEN multiple commands WHEN the observer is notified THEN the callback is only invoked for the tabs closed commands`() {
+ val callback: (Device?, List) -> Unit = mock()
+ val observer = TabsClosedEventsObserver(callback)
+ val events = listOf(
+ AccountEvent.ProfileUpdated,
+ AccountEvent.DeviceCommandIncoming(
+ command = DeviceCommandIncoming.TabsClosed(
+ device123,
+ listOf("https://mozilla.org"),
+ ),
+ ),
+ )
+
+ observer.onEvents(events)
+
+ verify(callback, times(1)).invoke(eq(device123), eq(listOf("https://mozilla.org")))
+ }
+
+ @Test
+ fun `GIVEN multiple tabs closed commands from the same device WHEN the observer is notified THEN the callback is invoked once`() {
+ val callback: (Device?, List) -> Unit = mock()
+ val observer = TabsClosedEventsObserver(callback)
+ val events = listOf(
+ AccountEvent.DeviceCommandIncoming(
+ command = DeviceCommandIncoming.TabsClosed(
+ device123,
+ listOf("https://mozilla.org", "https://getfirefox.com"),
+ ),
+ ),
+ AccountEvent.DeviceCommandIncoming(
+ command = DeviceCommandIncoming.TabsClosed(
+ device123,
+ listOf("https://example.org"),
+ ),
+ ),
+ AccountEvent.DeviceCommandIncoming(
+ command = DeviceCommandIncoming.TabsClosed(
+ device123,
+ listOf("https://getthunderbird.com"),
+ ),
+ ),
+ )
+
+ observer.onEvents(events)
+
+ verify(callback, times(1)).invoke(
+ eq(device123),
+ eq(
+ listOf(
+ "https://mozilla.org",
+ "https://getfirefox.com",
+ "https://example.org",
+ "https://getthunderbird.com",
+ ),
+ ),
+ )
+ }
+
+ @Test
+ fun `GIVEN multiple tabs closed commands from different devices WHEN the observer is notified THEN the callback is invoked once per device`() {
+ val callback: (Device?, List) -> Unit = mock()
+ val observer = TabsClosedEventsObserver(callback)
+ val events = listOf(
+ AccountEvent.DeviceCommandIncoming(
+ command = DeviceCommandIncoming.TabsClosed(
+ null,
+ listOf("https://mozilla.org"),
+ ),
+ ),
+ AccountEvent.DeviceCommandIncoming(
+ command = DeviceCommandIncoming.TabsClosed(
+ device123,
+ listOf("https://mozilla.org"),
+ ),
+ ),
+ AccountEvent.DeviceCommandIncoming(
+ command = DeviceCommandIncoming.TabsClosed(
+ device1234,
+ listOf("https://mozilla.org"),
+ ),
+ ),
+ AccountEvent.DeviceCommandIncoming(
+ command = DeviceCommandIncoming.TabsClosed(
+ device12345,
+ listOf("https://mozilla.org"),
+ ),
+ ),
+ )
+
+ observer.onEvents(events)
+
+ verify(callback, times(4)).invoke(any(), eq(listOf("https://mozilla.org")))
+ }
+}
diff --git a/mobile/android/android-components/components/feature/accounts/src/main/assets/extensions/fxawebchannel/background.js b/mobile/android/android-components/components/feature/accounts/src/main/assets/extensions/fxawebchannel/background.js
index b90f57154a..e8cf40ba8d 100644
--- a/mobile/android/android-components/components/feature/accounts/src/main/assets/extensions/fxawebchannel/background.js
+++ b/mobile/android/android-components/components/feature/accounts/src/main/assets/extensions/fxawebchannel/background.js
@@ -10,12 +10,12 @@ let port = browser.runtime.connectNative(WEB_CHANNEL_BACKGROUND_MESSAGING_ID);
/*
Handle messages from native application, register content script for specific url.
*/
-port.onMessage.addListener( event => {
- if(event.type == "overrideFxAServer"){
+port.onMessage.addListener(event => {
+ if (event.type == "overrideFxAServer") {
browser.contentScripts.register({
- "matches": [ event.url+"/*" ],
- "js": [{file: "fxawebchannel.js"}],
- "runAt": "document_start"
+ matches: [event.url + "/*"],
+ js: [{ file: "fxawebchannel.js" }],
+ runAt: "document_start",
});
port.disconnect();
}
diff --git a/mobile/android/android-components/components/feature/accounts/src/main/assets/extensions/fxawebchannel/fxawebchannel.js b/mobile/android/android-components/components/feature/accounts/src/main/assets/extensions/fxawebchannel/fxawebchannel.js
index 2f5934dff1..16614d3069 100644
--- a/mobile/android/android-components/components/feature/accounts/src/main/assets/extensions/fxawebchannel/fxawebchannel.js
+++ b/mobile/android/android-components/components/feature/accounts/src/main/assets/extensions/fxawebchannel/fxawebchannel.js
@@ -10,16 +10,18 @@ let port = browser.runtime.connectNative("mozacWebchannel");
/*
Handle messages from native application, dispatch them to FxA via an event.
*/
-port.onMessage.addListener((event) => {
- window.dispatchEvent(new CustomEvent('WebChannelMessageToContent', {
- detail: JSON.stringify(event)
- }));
+port.onMessage.addListener(event => {
+ window.dispatchEvent(
+ new CustomEvent("WebChannelMessageToContent", {
+ detail: JSON.stringify(event),
+ })
+ );
});
/*
Handle messages from FxA. Messages are posted to the native application for processing.
*/
-window.addEventListener('WebChannelMessageToChrome', function (e) {
+window.addEventListener("WebChannelMessageToChrome", function (e) {
const detail = JSON.parse(e.detail);
port.postMessage(detail);
});
diff --git a/mobile/android/android-components/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeature.kt b/mobile/android/android-components/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeature.kt
index 60913282b7..b244eb0de8 100644
--- a/mobile/android/android-components/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeature.kt
+++ b/mobile/android/android-components/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeature.kt
@@ -38,10 +38,15 @@ class FirefoxAccountsAuthFeature(
* @param context [Context] The application context
* @param entrypoint [FxAEntryPoint] The Firefox Accounts feature/entrypoint that is launching
* authentication
+ * @param scopes [Set] The oAuth scopes being requested
*/
- fun beginAuthentication(context: Context, entrypoint: FxAEntryPoint) {
+ fun beginAuthentication(
+ context: Context,
+ entrypoint: FxAEntryPoint,
+ scopes: Set = emptySet(),
+ ) {
beginAuthenticationAsync(context) {
- accountManager.beginAuthentication(entrypoint = entrypoint)
+ accountManager.beginAuthentication(entrypoint = entrypoint, authScopes = scopes)
}
}
@@ -50,15 +55,17 @@ class FirefoxAccountsAuthFeature(
* @param context [Context] The application context
* @param pairingUrl [String] The pairing URL retrieved from the QR scanner
* @param entrypoint [FxAEntryPoint] The Firefox Accounts feature/entrypoint that is launching
+ * @param scopes [Set] The oAuth scopes being requested
* authentication
*/
fun beginPairingAuthentication(
context: Context,
pairingUrl: String,
entrypoint: FxAEntryPoint,
+ scopes: Set = emptySet(),
) {
beginAuthenticationAsync(context) {
- accountManager.beginAuthentication(pairingUrl, entrypoint = entrypoint)
+ accountManager.beginAuthentication(pairingUrl, entrypoint = entrypoint, scopes)
}
}
diff --git a/mobile/android/android-components/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FxaWebChannelFeature.kt b/mobile/android/android-components/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FxaWebChannelFeature.kt
index f5554c99e4..377d6b0d93 100644
--- a/mobile/android/android-components/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FxaWebChannelFeature.kt
+++ b/mobile/android/android-components/components/feature/accounts/src/main/java/mozilla/components/feature/accounts/FxaWebChannelFeature.kt
@@ -20,6 +20,7 @@ import mozilla.components.concept.engine.webextension.MessageHandler
import mozilla.components.concept.engine.webextension.Port
import mozilla.components.concept.engine.webextension.WebExtensionRuntime
import mozilla.components.concept.sync.AuthType
+import mozilla.components.concept.sync.UserData
import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.service.fxa.FxaAuthData
import mozilla.components.service.fxa.ServerConfig
@@ -157,6 +158,7 @@ class FxaWebChannelFeature(
WebChannelCommand.CAN_LINK_ACCOUNT -> processCanLinkAccountCommand(messageId)
WebChannelCommand.FXA_STATUS -> processFxaStatusCommand(accountManager, messageId, fxaCapabilities)
WebChannelCommand.OAUTH_LOGIN -> processOauthLoginCommand(accountManager, payload)
+ WebChannelCommand.LOGIN -> processLoginCommand(accountManager, payload)
}
response?.let { port.postMessage(it) }
}
@@ -195,6 +197,7 @@ class FxaWebChannelFeature(
enum class WebChannelCommand {
CAN_LINK_ACCOUNT,
+ LOGIN,
OAUTH_LOGIN,
FXA_STATUS,
}
@@ -221,6 +224,12 @@ class FxaWebChannelFeature(
*/
private const val COMMAND_STATUS = "fxaccounts:fxa_status"
+ /**
+ * Gets triggered when the web content is signed in/up, but not necessarily verified
+ * it passes in its payload the session token the web content is holding on to
+ */
+ private const val COMMAND_LOGIN = "fxaccounts:login"
+
/**
* Handles the [COMMAND_CAN_LINK_ACCOUNT] event from the web-channel.
* Currently this always response with 'ok=true'.
@@ -328,6 +337,32 @@ class FxaWebChannelFeature(
return result
}
+ /**
+ * Handles the [COMMAND_LOGIN] event from the web-channel
+ */
+ private fun processLoginCommand(accountManager: FxaAccountManager, payload: JSONObject): JSONObject? {
+ val sessionToken: String
+ val email: String
+ val uid: String
+ val verified: Boolean
+
+ try {
+ val data = payload.getJSONObject("data")
+ sessionToken = data.getString("sessionToken")
+ email = data.getString("email")
+ uid = data.getString("uid")
+ verified = data.getBoolean("verified")
+ } catch (e: JSONException) {
+ logger.error("Error while processing WebChannel login command", e)
+ return null
+ }
+ val userData = UserData(sessionToken, email, uid, verified)
+ CoroutineScope(Dispatchers.Main).launch {
+ accountManager.setUserData(userData)
+ }
+ return null
+ }
+
/**
* Handles the [COMMAND_OAUTH_LOGIN] event from the web-channel.
*/
@@ -368,6 +403,7 @@ class FxaWebChannelFeature(
COMMAND_CAN_LINK_ACCOUNT -> WebChannelCommand.CAN_LINK_ACCOUNT
COMMAND_OAUTH_LOGIN -> WebChannelCommand.OAUTH_LOGIN
COMMAND_STATUS -> WebChannelCommand.FXA_STATUS
+ COMMAND_LOGIN -> WebChannelCommand.LOGIN
else -> {
logger.warn("Unrecognized WebChannel command: $this")
null
diff --git a/mobile/android/android-components/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeatureTest.kt b/mobile/android/android-components/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeatureTest.kt
index a32681e8fe..ef7f78b336 100644
--- a/mobile/android/android-components/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeatureTest.kt
+++ b/mobile/android/android-components/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FirefoxAccountsAuthFeatureTest.kt
@@ -17,8 +17,8 @@ import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.DeviceConfig
import mozilla.components.concept.sync.DeviceType
import mozilla.components.concept.sync.FxAEntryPoint
-import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.concept.sync.Profile
+import mozilla.components.service.fxa.FirefoxAccount
import mozilla.components.service.fxa.FxaAuthData
import mozilla.components.service.fxa.ServerConfig
import mozilla.components.service.fxa.StorageWrapper
@@ -45,13 +45,13 @@ internal class TestableStorageWrapper(
manager: FxaAccountManager,
accountEventObserverRegistry: ObserverRegistry,
serverConfig: ServerConfig,
- private val block: () -> OAuthAccount = {
- val account: OAuthAccount = mock()
+ private val block: () -> FirefoxAccount = {
+ val account: FirefoxAccount = mock()
`when`(account.deviceConstellation()).thenReturn(mock())
account
},
) : StorageWrapper(manager, accountEventObserverRegistry, serverConfig) {
- override fun obtainAccount(): OAuthAccount = block()
+ override fun obtainAccount(): FirefoxAccount = block()
}
// Same as the actual account manager, except we get to control how FirefoxAccountShaped instances
@@ -63,7 +63,7 @@ class TestableFxaAccountManager(
config: ServerConfig,
scopes: Set,
coroutineContext: CoroutineContext,
- block: () -> OAuthAccount = { mock() },
+ block: () -> FirefoxAccount = { mock() },
) : FxaAccountManager(context, config, DeviceConfig("test", DeviceType.MOBILE, setOf()), null, scopes, null, coroutineContext) {
private val testableStorageWrapper = TestableStorageWrapper(this, accountEventObserverRegistry, serverConfig, block)
override fun getStorageWrapper(): StorageWrapper {
@@ -252,7 +252,7 @@ class FirefoxAccountsAuthFeatureTest {
private suspend fun prepareAccountManagerForSuccessfulAuthentication(
coroutineContext: CoroutineContext,
): TestableFxaAccountManager {
- val mockAccount: OAuthAccount = mock()
+ val mockAccount: FirefoxAccount = mock()
val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile")
`when`(mockAccount.deviceConstellation()).thenReturn(mock())
@@ -279,7 +279,7 @@ class FirefoxAccountsAuthFeatureTest {
private suspend fun prepareAccountManagerForFailedAuthentication(
coroutineContext: CoroutineContext,
): TestableFxaAccountManager {
- val mockAccount: OAuthAccount = mock()
+ val mockAccount: FirefoxAccount = mock()
val profile = Profile(uid = "testUID", avatar = null, email = "test@example.com", displayName = "test profile")
`when`(mockAccount.getProfile(anyBoolean())).thenReturn(profile)
diff --git a/mobile/android/android-components/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FxaWebChannelFeatureTest.kt b/mobile/android/android-components/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FxaWebChannelFeatureTest.kt
index 809ed7a703..3a49633f61 100644
--- a/mobile/android/android-components/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FxaWebChannelFeatureTest.kt
+++ b/mobile/android/android-components/components/feature/accounts/src/test/java/mozilla/components/feature/accounts/FxaWebChannelFeatureTest.kt
@@ -19,6 +19,7 @@ import mozilla.components.concept.engine.webextension.WebExtension
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.concept.sync.Profile
+import mozilla.components.concept.sync.UserData
import mozilla.components.service.fxa.FxaAuthData
import mozilla.components.service.fxa.ServerConfig
import mozilla.components.service.fxa.SyncEngine
@@ -701,6 +702,56 @@ class FxaWebChannelFeatureTest {
assertTrue(FxaWebChannelFeature.isCommunicationAllowed("http://localhost", "http://localhost"))
}
+ @Test
+ fun `COMMAND_LOGIN must be processed and sets the user's data`() = runTest {
+ val accountManager: FxaAccountManager = mock() // syncConfig is null by default (is not configured)
+ val engineSession: EngineSession = mock()
+ val ext: WebExtension = mock()
+ val port: Port = mock()
+ val messageHandler = argumentCaptor()
+
+ WebExtensionController.installedExtensions[FxaWebChannelFeature.WEB_CHANNEL_EXTENSION_ID] = ext
+
+ val webchannelFeature = prepareFeatureForTest(ext, port, engineSession, null, emptySet(), accountManager)
+ webchannelFeature.start()
+ shadowOf(getMainLooper()).idle()
+
+ verify(ext).registerContentMessageHandler(
+ eq(engineSession),
+ eq(FxaWebChannelFeature.WEB_CHANNEL_MESSAGING_ID),
+ messageHandler.capture(),
+ )
+ messageHandler.value.onPortConnected(port)
+
+ // Action: signin
+ verifyLogin("sessiontoken123", "foo@bar.com", "uid123", false, messageHandler.value, accountManager)
+ }
+
+ @Test
+ fun `COMMAND_LOGIN invalid json sends back`() = runTest {
+ val accountManager: FxaAccountManager = mock() // syncConfig is null by default (is not configured)
+ val engineSession: EngineSession = mock()
+ val ext: WebExtension = mock()
+ val port: Port = mock()
+ val messageHandler = argumentCaptor()
+
+ WebExtensionController.installedExtensions[FxaWebChannelFeature.WEB_CHANNEL_EXTENSION_ID] = ext
+
+ val webchannelFeature = prepareFeatureForTest(ext, port, engineSession, null, emptySet(), accountManager)
+ webchannelFeature.start()
+ shadowOf(getMainLooper()).idle()
+
+ verify(ext).registerContentMessageHandler(
+ eq(engineSession),
+ eq(FxaWebChannelFeature.WEB_CHANNEL_MESSAGING_ID),
+ messageHandler.capture(),
+ )
+ messageHandler.value.onPortConnected(port)
+
+ // Action: signin
+ verifyLogin("sessiontoken123", "foo@bar.com", "uid123", false, messageHandler.value, accountManager)
+ }
+
private fun JSONObject.getSupportedEngines(): List {
val engines = this.getJSONObject("message")
.getJSONObject("data")
@@ -798,6 +849,41 @@ class FxaWebChannelFeatureTest {
)
}
+ private suspend fun verifyLogin(sessionToken: String, email: String, uid: String, verified: Boolean, messageHandler: MessageHandler, accountManager: FxaAccountManager) {
+ val jsonToWebChannel = jsonLogin(sessionToken, email, uid, verified)
+ val port = mock()
+ whenever(port.senderUrl()).thenReturn("https://foo.bar/email")
+ messageHandler.onPortMessage(jsonToWebChannel, port)
+
+ val expectedUserData = UserData(
+ sessionToken = sessionToken,
+ email = email,
+ uid = uid,
+ verified = verified,
+ )
+ shadowOf(getMainLooper()).idle()
+
+ verify(accountManager).setUserData(expectedUserData)
+ }
+
+ private fun jsonLogin(sessionToken: String, email: String, uid: String, verified: Boolean): JSONObject {
+ return JSONObject(
+ """{
+ "message":{
+ "command": "fxaccounts:login",
+ "messageId":123,
+ "data":{
+ "email":"$email",
+ "sessionToken":"$sessionToken",
+ "uid":"$uid",
+ "verified":$verified
+ }
+ }
+ }
+ """.trimIndent(),
+ )
+ }
+
private fun prepareFeatureForTest(
ext: WebExtension = mock(),
port: Port = mock(),
diff --git a/mobile/android/android-components/components/feature/addons/src/main/java/mozilla/components/feature/addons/AddonManager.kt b/mobile/android/android-components/components/feature/addons/src/main/java/mozilla/components/feature/addons/AddonManager.kt
index d3e12a0171..723d3e6eb1 100644
--- a/mobile/android/android-components/components/feature/addons/src/main/java/mozilla/components/feature/addons/AddonManager.kt
+++ b/mobile/android/android-components/components/feature/addons/src/main/java/mozilla/components/feature/addons/AddonManager.kt
@@ -252,9 +252,9 @@ class AddonManager(
permissions = permissions,
origins = origins,
onSuccess = { ext ->
- val enabledAddon = addon.copy(installedState = toInstalledState(ext))
+ val updatedAddon = Addon.newFromWebExtension(ext, toInstalledState(ext))
completePendingAddonAction(pendingAction)
- onSuccess(enabledAddon)
+ onSuccess(updatedAddon)
},
onError = {
completePendingAddonAction(pendingAction)
@@ -296,9 +296,9 @@ class AddonManager(
permissions = permissions,
origins = origins,
onSuccess = { ext ->
- val enabledAddon = addon.copy(installedState = toInstalledState(ext))
+ val updatedAddon = Addon.newFromWebExtension(ext, toInstalledState(ext))
completePendingAddonAction(pendingAction)
- onSuccess(enabledAddon)
+ onSuccess(updatedAddon)
},
onError = {
completePendingAddonAction(pendingAction)
diff --git a/mobile/android/android-components/components/feature/addons/src/main/java/mozilla/components/feature/addons/ui/PermissionsDialogFragment.kt b/mobile/android/android-components/components/feature/addons/src/main/java/mozilla/components/feature/addons/ui/PermissionsDialogFragment.kt
index 92e555e722..a19a5a61f5 100644
--- a/mobile/android/android-components/components/feature/addons/src/main/java/mozilla/components/feature/addons/ui/PermissionsDialogFragment.kt
+++ b/mobile/android/android-components/components/feature/addons/src/main/java/mozilla/components/feature/addons/ui/PermissionsDialogFragment.kt
@@ -153,9 +153,10 @@ class PermissionsDialogFragment : AddonDialogFragment() {
},
addon.translateName(requireContext()),
)
- rootView.findViewById(R.id.optional_or_required_text).text = buildOptionalOrRequiredText()
-
val listPermissions = buildPermissionsList()
+ rootView.findViewById(R.id.optional_or_required_text).text =
+ buildOptionalOrRequiredText(listPermissions.isNotEmpty())
+
val permissionsRecyclerView = rootView.findViewById(R.id.permissions)
val positiveButton = rootView.findViewById