summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/feature/customtabs/src
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:42 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:34:42 +0000
commitda4c7e7ed675c3bf405668739c3012d140856109 (patch)
treecdd868dba063fecba609a1d819de271f0d51b23e /mobile/android/android-components/components/feature/customtabs/src
parentAdding upstream version 125.0.3. (diff)
downloadfirefox-da4c7e7ed675c3bf405668739c3012d140856109.tar.xz
firefox-da4c7e7ed675c3bf405668739c3012d140856109.zip
Adding upstream version 126.0.upstream/126.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/feature/customtabs/src')
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/AbstractCustomTabsService.kt135
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabConfigHelper.kt280
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabIntentProcessor.kt74
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabWindowFeature.kt107
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabsFacts.kt37
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabsToolbarFeature.kt425
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/feature/CustomTabSessionTitleObserver.kt50
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/feature/OriginVerifierFeature.kt67
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/menu/CustomTabMenuCandidates.kt32
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/store/CustomTabsAction.kt40
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/store/CustomTabsServiceState.kt68
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/store/CustomTabsServiceStateReducer.kt28
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/store/CustomTabsServiceStore.kt15
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/verify/OriginVerifier.kt76
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-am/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-an/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ar/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ast/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-az/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-azb/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ban/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-be/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-bg/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-bn/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-br/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-bs/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ca/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-cak/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ceb/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ckb/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-co/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-cs/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-cy/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-da/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-de/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-dsb/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-el/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-en-rCA/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-en-rGB/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-eo/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-es-rAR/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-es-rCL/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-es-rES/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-es-rMX/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-es/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-et/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-eu/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-fa/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ff/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-fi/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-fr/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-fur/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-fy-rNL/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ga-rIE/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-gd/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-gl/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-gn/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-gu-rIN/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-hi-rIN/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-hr/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-hsb/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-hu/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-hy-rAM/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ia/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-in/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-is/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-it/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-iw/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ja/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ka/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-kaa/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-kab/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-kk/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-kmr/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-kn/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ko/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-lij/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-lo/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-lt/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ml/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-mr/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-my/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-nb-rNO/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ne-rNP/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-nl/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-nn-rNO/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-oc/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-or/strings.xml4
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-pa-rIN/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-pa-rPK/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-pl/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-pt-rBR/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-pt-rPT/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-rm/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ro/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ru/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-sat/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-sc/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-si/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-sk/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-skr/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-sl/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-sq/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-sr/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-su/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-sv-rSE/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ta/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-te/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-tg/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-th/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-tl/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-tok/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-tr/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-trs/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-tt/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-tzm/strings.xml4
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ug/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-uk/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-ur/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-uz/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-vec/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-vi/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-yo/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-zh-rCN/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values-zh-rTW/strings.xml5
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values/dimens.xml7
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/main/res/values/strings.xml11
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/AbstractCustomTabsServiceTest.kt130
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/CustomTabConfigHelperTest.kt416
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/CustomTabIntentProcessorTest.kt175
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/CustomTabWindowFeatureTest.kt164
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/CustomTabsToolbarFeatureTest.kt1582
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/feature/CustomTabSessionTitleObserverTest.kt113
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/feature/OriginVerifierFeatureTest.kt82
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/menu/CustomTabMenuCandidatesTest.kt77
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/store/CustomTabsServiceStateReducerTest.kt174
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/verify/OriginVerifierTest.kt85
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker3
-rw-r--r--mobile/android/android-components/components/feature/customtabs/src/test/resources/robolectric.properties1
140 files changed, 5011 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/AndroidManifest.xml b/mobile/android/android-components/components/feature/customtabs/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..e16cda1d34
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<manifest />
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/AbstractCustomTabsService.kt b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/AbstractCustomTabsService.kt
new file mode 100644
index 0000000000..7a96b72cfc
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/AbstractCustomTabsService.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.feature.customtabs
+
+import android.app.Service
+import android.net.Uri
+import android.os.Binder
+import android.os.Bundle
+import androidx.annotation.VisibleForTesting
+import androidx.browser.customtabs.CustomTabsService
+import androidx.browser.customtabs.CustomTabsSessionToken
+import kotlinx.coroutines.Dispatchers.Main
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import mozilla.components.concept.engine.Engine
+import mozilla.components.feature.customtabs.feature.OriginVerifierFeature
+import mozilla.components.feature.customtabs.store.CustomTabsServiceStore
+import mozilla.components.feature.customtabs.store.SaveCreatorPackageNameAction
+import mozilla.components.service.digitalassetlinks.RelationChecker
+import mozilla.components.support.base.log.logger.Logger
+import mozilla.components.support.utils.ext.getParcelableCompat
+
+/**
+ * Maximum number of speculative connections we will open when an app calls into
+ * [AbstractCustomTabsService.mayLaunchUrl] with a list of URLs.
+ */
+private const val MAX_SPECULATIVE_URLS = 50
+
+/**
+ * [Service] providing Custom Tabs related functionality.
+ */
+abstract class AbstractCustomTabsService : CustomTabsService() {
+ private val logger = Logger("CustomTabsService")
+ private val scope = MainScope()
+
+ abstract val engine: Engine
+ abstract val customTabsServiceStore: CustomTabsServiceStore
+ open val relationChecker: RelationChecker? = null
+
+ @VisibleForTesting
+ internal val verifier by lazy {
+ relationChecker?.let { checker ->
+ OriginVerifierFeature(packageManager, checker) { customTabsServiceStore.dispatch(it) }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ scope.cancel()
+ }
+
+ override fun warmup(flags: Long): Boolean {
+ // We need to run this on the main thread since that's where GeckoRuntime expects to get initialized (if needed)
+ return runBlocking(Main) {
+ engine.warmUp()
+ true
+ }
+ }
+
+ override fun requestPostMessageChannel(sessionToken: CustomTabsSessionToken, postMessageOrigin: Uri): Boolean {
+ return false
+ }
+
+ /**
+ * Saves the package name of the app creating the custom tab when a new session is started.
+ */
+ override fun newSession(sessionToken: CustomTabsSessionToken): Boolean {
+ // Extract the process UID of the app creating the custom tab.
+ val uid = Binder.getCallingUid()
+ // Only save the package if exactly one package name maps to the process UID.
+ val packageName = packageManager.getPackagesForUid(uid)?.singleOrNull()
+
+ if (!packageName.isNullOrEmpty()) {
+ customTabsServiceStore.dispatch(SaveCreatorPackageNameAction(sessionToken, packageName))
+ }
+ return true
+ }
+
+ override fun extraCommand(commandName: String, args: Bundle?): Bundle? = null
+
+ override fun mayLaunchUrl(
+ sessionToken: CustomTabsSessionToken,
+ url: Uri?,
+ extras: Bundle?,
+ otherLikelyBundles: List<Bundle>?,
+ ): Boolean {
+ logger.debug("Opening speculative connections")
+
+ // Most likely URL for a future navigation: Open a speculative connection.
+ url?.let { engine.speculativeConnect(it.toString()) }
+
+ // A list of other likely URLs. Let's open a speculative connection for them up to a limit.
+ otherLikelyBundles?.take(MAX_SPECULATIVE_URLS)?.forEach { bundle ->
+ bundle.getParcelableCompat(KEY_URL, Uri::class.java)?.let { uri ->
+ engine.speculativeConnect(uri.toString())
+ }
+ }
+
+ return true
+ }
+
+ override fun postMessage(sessionToken: CustomTabsSessionToken, message: String, extras: Bundle?) =
+ RESULT_FAILURE_DISALLOWED
+
+ override fun validateRelationship(
+ sessionToken: CustomTabsSessionToken,
+ @Relation relation: Int,
+ origin: Uri,
+ extras: Bundle?,
+ ): Boolean {
+ val verifier = verifier
+ val state = customTabsServiceStore.state.tabs[sessionToken]
+ return if (verifier != null && state != null) {
+ scope.launch(Main) {
+ val result = verifier.verify(state, sessionToken, relation, origin)
+ sessionToken.callback?.onRelationshipValidationResult(relation, origin, result, extras)
+ }
+ true
+ } else {
+ false
+ }
+ }
+
+ override fun updateVisuals(sessionToken: CustomTabsSessionToken, bundle: Bundle?): Boolean {
+ return false
+ }
+
+ override fun receiveFile(sessionToken: CustomTabsSessionToken, uri: Uri, purpose: Int, extras: Bundle?): Boolean {
+ return false
+ }
+}
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabConfigHelper.kt b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabConfigHelper.kt
new file mode 100644
index 0000000000..2d5fe96e9a
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabConfigHelper.kt
@@ -0,0 +1,280 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.customtabs
+
+import android.app.PendingIntent
+import android.content.Intent
+import android.content.res.Resources
+import android.graphics.Bitmap
+import android.os.Build
+import android.os.Bundle
+import android.os.Parcelable
+import androidx.annotation.ColorInt
+import androidx.annotation.VisibleForTesting
+import androidx.browser.customtabs.CustomTabColorSchemeParams
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.browser.customtabs.CustomTabsIntent.ColorScheme
+import androidx.browser.customtabs.CustomTabsIntent.EXTRA_ACTION_BUTTON_BUNDLE
+import androidx.browser.customtabs.CustomTabsIntent.EXTRA_CLOSE_BUTTON_ICON
+import androidx.browser.customtabs.CustomTabsIntent.EXTRA_COLOR_SCHEME
+import androidx.browser.customtabs.CustomTabsIntent.EXTRA_COLOR_SCHEME_PARAMS
+import androidx.browser.customtabs.CustomTabsIntent.EXTRA_ENABLE_URLBAR_HIDING
+import androidx.browser.customtabs.CustomTabsIntent.EXTRA_EXIT_ANIMATION_BUNDLE
+import androidx.browser.customtabs.CustomTabsIntent.EXTRA_MENU_ITEMS
+import androidx.browser.customtabs.CustomTabsIntent.EXTRA_NAVIGATION_BAR_COLOR
+import androidx.browser.customtabs.CustomTabsIntent.EXTRA_NAVIGATION_BAR_DIVIDER_COLOR
+import androidx.browser.customtabs.CustomTabsIntent.EXTRA_SECONDARY_TOOLBAR_COLOR
+import androidx.browser.customtabs.CustomTabsIntent.EXTRA_SESSION
+import androidx.browser.customtabs.CustomTabsIntent.EXTRA_SHARE_STATE
+import androidx.browser.customtabs.CustomTabsIntent.EXTRA_TINT_ACTION_BUTTON
+import androidx.browser.customtabs.CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE
+import androidx.browser.customtabs.CustomTabsIntent.EXTRA_TOOLBAR_COLOR
+import androidx.browser.customtabs.CustomTabsIntent.KEY_DESCRIPTION
+import androidx.browser.customtabs.CustomTabsIntent.KEY_ICON
+import androidx.browser.customtabs.CustomTabsIntent.KEY_ID
+import androidx.browser.customtabs.CustomTabsIntent.KEY_MENU_ITEM_TITLE
+import androidx.browser.customtabs.CustomTabsIntent.KEY_PENDING_INTENT
+import androidx.browser.customtabs.CustomTabsIntent.NO_TITLE
+import androidx.browser.customtabs.CustomTabsIntent.SHARE_STATE_DEFAULT
+import androidx.browser.customtabs.CustomTabsIntent.SHARE_STATE_ON
+import androidx.browser.customtabs.CustomTabsIntent.SHOW_PAGE_TITLE
+import androidx.browser.customtabs.CustomTabsIntent.TOOLBAR_ACTION_BUTTON_ID
+import androidx.browser.customtabs.CustomTabsSessionToken
+import androidx.browser.customtabs.TrustedWebUtils.EXTRA_LAUNCH_AS_TRUSTED_WEB_ACTIVITY
+import mozilla.components.browser.state.state.ColorSchemeParams
+import mozilla.components.browser.state.state.ColorSchemes
+import mozilla.components.browser.state.state.CustomTabActionButtonConfig
+import mozilla.components.browser.state.state.CustomTabConfig
+import mozilla.components.browser.state.state.CustomTabMenuItem
+import mozilla.components.browser.state.state.ExternalAppType
+import mozilla.components.support.utils.SafeIntent
+import mozilla.components.support.utils.toSafeBundle
+import mozilla.components.support.utils.toSafeIntent
+import kotlin.math.max
+
+/**
+ * Checks if the provided intent is a custom tab intent.
+ *
+ * @param intent the intent to check.
+ * @return true if the intent is a custom tab intent, otherwise false.
+ */
+fun isCustomTabIntent(intent: Intent) = isCustomTabIntent(intent.toSafeIntent())
+
+/**
+ * Checks if the provided intent is a custom tab intent.
+ *
+ * @param safeIntent the intent to check, wrapped as a SafeIntent.
+ * @return true if the intent is a custom tab intent, otherwise false.
+ */
+fun isCustomTabIntent(safeIntent: SafeIntent) = safeIntent.hasExtra(EXTRA_SESSION)
+
+/**
+ * Checks if the provided intent is a trusted web activity intent.
+ *
+ * @param intent the intent to check.
+ * @return true if the intent is a trusted web activity intent, otherwise false.
+ */
+fun isTrustedWebActivityIntent(intent: Intent) = isTrustedWebActivityIntent(intent.toSafeIntent())
+
+/**
+ * Checks if the provided intent is a trusted web activity intent.
+ *
+ * @param safeIntent the intent to check, wrapped as a SafeIntent.
+ * @return true if the intent is a trusted web activity intent, otherwise false.
+ */
+fun isTrustedWebActivityIntent(safeIntent: SafeIntent) = isCustomTabIntent(safeIntent) &&
+ safeIntent.getBooleanExtra(EXTRA_LAUNCH_AS_TRUSTED_WEB_ACTIVITY, false)
+
+/**
+ * Creates a [CustomTabConfig] instance based on the provided [Intent].
+ *
+ * @param intent The [Intent] wrapped as a [SafeIntent], which is processed to extract configuration data.
+ * @param resources Optional [Resources] to verify that only icons of a max size are provided.
+ *
+ * @return the configured [CustomTabConfig].
+ */
+fun createCustomTabConfigFromIntent(intent: Intent, resources: Resources?): CustomTabConfig {
+ val safeIntent = intent.toSafeIntent()
+
+ return CustomTabConfig(
+ colorScheme = safeIntent.getColorExtra(EXTRA_COLOR_SCHEME),
+ colorSchemes = getColorSchemes(safeIntent),
+ closeButtonIcon = getCloseButtonIcon(safeIntent, resources),
+ enableUrlbarHiding = safeIntent.getBooleanExtra(EXTRA_ENABLE_URLBAR_HIDING, false),
+ actionButtonConfig = getActionButtonConfig(safeIntent),
+ showShareMenuItem = (safeIntent.getIntExtra(EXTRA_SHARE_STATE, SHARE_STATE_DEFAULT) == SHARE_STATE_ON),
+ menuItems = getMenuItems(safeIntent),
+ exitAnimations = safeIntent.getBundleExtra(EXTRA_EXIT_ANIMATION_BUNDLE)?.unsafe,
+ titleVisible = safeIntent.getIntExtra(EXTRA_TITLE_VISIBILITY_STATE, NO_TITLE) == SHOW_PAGE_TITLE,
+ sessionToken = if (intent.extras != null) {
+ // getSessionTokenFromIntent throws if extras is null
+ CustomTabsSessionToken.getSessionTokenFromIntent(intent)
+ } else {
+ null
+ },
+ externalAppType = ExternalAppType.CUSTOM_TAB,
+ )
+}
+
+@ColorInt
+private fun SafeIntent.getColorExtra(name: String): Int? =
+ if (hasExtra(name)) getIntExtra(name, 0) else null
+
+private fun getCloseButtonIcon(intent: SafeIntent, resources: Resources?): Bitmap? {
+ val icon = try {
+ intent.getParcelableExtra(EXTRA_CLOSE_BUTTON_ICON, Bitmap::class.java)
+ } catch (e: ClassCastException) {
+ null
+ }
+ val maxSize = resources?.getDimension(R.dimen.mozac_feature_customtabs_max_close_button_size) ?: Float.MAX_VALUE
+
+ return if (icon != null && max(icon.width, icon.height) <= maxSize) {
+ icon
+ } else {
+ null
+ }
+}
+
+private fun getColorSchemes(safeIntent: SafeIntent): ColorSchemes? {
+ val defaultColorSchemeParams = getDefaultSchemeColorParams(safeIntent)
+ val lightColorSchemeParams = getLightColorSchemeParams(safeIntent)
+ val darkColorSchemeParams = getDarkColorSchemeParams(safeIntent)
+
+ return if (allNull(defaultColorSchemeParams, lightColorSchemeParams, darkColorSchemeParams)) {
+ null
+ } else {
+ ColorSchemes(
+ defaultColorSchemeParams = defaultColorSchemeParams,
+ lightColorSchemeParams = lightColorSchemeParams,
+ darkColorSchemeParams = darkColorSchemeParams,
+ )
+ }
+}
+
+/**
+ * Processes the given [SafeIntent] to extract possible default [CustomTabColorSchemeParams]
+ * properties.
+ *
+ * @param safeIntent the [SafeIntent] to process.
+ *
+ * @return the derived [ColorSchemeParams] or null if the [SafeIntent] had no default
+ * [CustomTabColorSchemeParams] properties.
+ *
+ * @see [CustomTabsIntent.Builder.setDefaultColorSchemeParams].
+ */
+private fun getDefaultSchemeColorParams(safeIntent: SafeIntent): ColorSchemeParams? {
+ val toolbarColor = safeIntent.getColorExtra(EXTRA_TOOLBAR_COLOR)
+ val secondaryToolbarColor = safeIntent.getColorExtra(EXTRA_SECONDARY_TOOLBAR_COLOR)
+ val navigationBarColor = safeIntent.getColorExtra(EXTRA_NAVIGATION_BAR_COLOR)
+ val navigationBarDividerColor = safeIntent.getColorExtra(EXTRA_NAVIGATION_BAR_DIVIDER_COLOR)
+
+ return if (allNull(
+ toolbarColor,
+ secondaryToolbarColor,
+ navigationBarColor,
+ navigationBarDividerColor,
+ )
+ ) {
+ null
+ } else {
+ ColorSchemeParams(
+ toolbarColor = toolbarColor,
+ secondaryToolbarColor = secondaryToolbarColor,
+ navigationBarColor = navigationBarColor,
+ navigationBarDividerColor = navigationBarDividerColor,
+ )
+ }
+}
+
+private fun getLightColorSchemeParams(safeIntent: SafeIntent) =
+ getColorSchemeParams(safeIntent, CustomTabsIntent.COLOR_SCHEME_LIGHT)
+
+private fun getDarkColorSchemeParams(safeIntent: SafeIntent) =
+ getColorSchemeParams(safeIntent, CustomTabsIntent.COLOR_SCHEME_DARK)
+
+/**
+ * Processes the given [SafeIntent] to extract possible [CustomTabColorSchemeParams] properties for
+ * the given [colorScheme].
+ *
+ * @param safeIntent The [SafeIntent] to process.
+ * @param colorScheme The [ColorScheme] to get the [ColorSchemeParams] for.
+ *
+ * @return the derived [ColorSchemeParams] for the given [ColorScheme], or null if the [SafeIntent]
+ * had no [CustomTabColorSchemeParams] properties for the [ColorScheme].
+ *
+ * @see [CustomTabsIntent.Builder.setColorSchemeParams].
+ */
+private fun getColorSchemeParams(safeIntent: SafeIntent, @ColorScheme colorScheme: Int): ColorSchemeParams? {
+ val bundle = safeIntent.getColorSchemeParamsBundle()?.get(colorScheme)
+
+ val toolbarColor = bundle?.getNullableSafeValue(EXTRA_TOOLBAR_COLOR)
+ val secondaryToolbarColor = bundle?.getNullableSafeValue(EXTRA_SECONDARY_TOOLBAR_COLOR)
+ val navigationBarColor = bundle?.getNullableSafeValue(EXTRA_NAVIGATION_BAR_COLOR)
+ val navigationBarDividerColor = bundle?.getNullableSafeValue(EXTRA_NAVIGATION_BAR_DIVIDER_COLOR)
+
+ return if (allNull(toolbarColor, secondaryToolbarColor, navigationBarColor, navigationBarDividerColor)) {
+ null
+ } else {
+ ColorSchemeParams(
+ toolbarColor = toolbarColor,
+ secondaryToolbarColor = secondaryToolbarColor,
+ navigationBarColor = navigationBarColor,
+ navigationBarDividerColor = navigationBarDividerColor,
+ )
+ }
+}
+
+private fun <T> allNull(vararg value: T?) = value.toList().all { it == null }
+
+@VisibleForTesting
+internal fun SafeIntent.getColorSchemeParamsBundle() = extras?.let {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ @Suppress("DEPRECATION")
+ it.getSparseParcelableArray(EXTRA_COLOR_SCHEME_PARAMS)
+ } else {
+ it.getSparseParcelableArray(EXTRA_COLOR_SCHEME_PARAMS, Bundle::class.java)
+ }
+}
+
+private fun Bundle.getNullableSafeValue(key: String) =
+ if (containsKey(key)) toSafeBundle().getInt(key) else null
+
+private fun getActionButtonConfig(intent: SafeIntent): CustomTabActionButtonConfig? {
+ val actionButtonBundle = intent.getBundleExtra(EXTRA_ACTION_BUTTON_BUNDLE) ?: return null
+ val description = actionButtonBundle.getString(KEY_DESCRIPTION)
+ val icon = actionButtonBundle.getParcelable(KEY_ICON, Bitmap::class.java)
+ val pendingIntent = actionButtonBundle.getParcelable(KEY_PENDING_INTENT, PendingIntent::class.java)
+ val id = actionButtonBundle.getInt(KEY_ID, TOOLBAR_ACTION_BUTTON_ID)
+ val tint = intent.getBooleanExtra(EXTRA_TINT_ACTION_BUTTON, false)
+
+ return if (description != null && icon != null && pendingIntent != null) {
+ CustomTabActionButtonConfig(
+ id = id,
+ description = description,
+ icon = icon,
+ pendingIntent = pendingIntent,
+ tint = tint,
+ )
+ } else {
+ null
+ }
+}
+
+private fun getMenuItems(intent: SafeIntent): List<CustomTabMenuItem> =
+ intent.getParcelableArrayListExtra(EXTRA_MENU_ITEMS, Parcelable::class.java).orEmpty()
+ .mapNotNull { menuItemBundle ->
+ val bundle = (menuItemBundle as? Bundle)?.toSafeBundle()
+ val name = bundle?.getString(KEY_MENU_ITEM_TITLE)
+ val pendingIntent = bundle?.getParcelable(KEY_PENDING_INTENT, PendingIntent::class.java)
+
+ if (name != null && pendingIntent != null) {
+ CustomTabMenuItem(
+ name = name,
+ pendingIntent = pendingIntent,
+ )
+ } else {
+ null
+ }
+ }
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabIntentProcessor.kt b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabIntentProcessor.kt
new file mode 100644
index 0000000000..8bd8ee2179
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabIntentProcessor.kt
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.customtabs
+
+import android.content.Intent
+import android.content.Intent.ACTION_VIEW
+import android.content.res.Resources
+import android.provider.Browser
+import androidx.annotation.VisibleForTesting
+import mozilla.components.browser.state.state.SessionState
+import mozilla.components.browser.state.state.externalPackage
+import mozilla.components.feature.intent.ext.putSessionId
+import mozilla.components.feature.intent.processing.IntentProcessor
+import mozilla.components.feature.tabs.CustomTabsUseCases
+import mozilla.components.support.utils.SafeIntent
+import mozilla.components.support.utils.toSafeIntent
+
+/**
+ * Processor for intents which trigger actions related to custom tabs.
+ */
+class CustomTabIntentProcessor(
+ private val addCustomTabUseCase: CustomTabsUseCases.AddCustomTabUseCase,
+ private val resources: Resources,
+ private val isPrivate: Boolean = false,
+) : IntentProcessor {
+
+ private fun matches(intent: Intent): Boolean {
+ val safeIntent = intent.toSafeIntent()
+ return safeIntent.action == ACTION_VIEW && isCustomTabIntent(safeIntent)
+ }
+
+ @VisibleForTesting
+ internal fun getAdditionalHeaders(intent: SafeIntent): Map<String, String>? {
+ val pairs = intent.getBundleExtra(Browser.EXTRA_HEADERS)
+ val headers = mutableMapOf<String, String>()
+ pairs?.keySet()?.forEach { key ->
+ val header = pairs.getString(key)
+ if (header != null) {
+ headers[key] = header
+ } else {
+ throw IllegalArgumentException("getAdditionalHeaders() intent bundle contains wrong key value pair")
+ }
+ }
+ return if (headers.isEmpty()) {
+ null
+ } else {
+ headers
+ }
+ }
+
+ override fun process(intent: Intent): Boolean {
+ val safeIntent = SafeIntent(intent)
+ val url = safeIntent.dataString
+
+ return if (!url.isNullOrEmpty() && matches(intent)) {
+ val config = createCustomTabConfigFromIntent(intent, resources)
+ val caller = safeIntent.externalPackage()
+ val customTabId = addCustomTabUseCase(
+ url,
+ config,
+ isPrivate,
+ getAdditionalHeaders(safeIntent),
+ source = SessionState.Source.External.CustomTab(caller),
+ )
+ intent.putSessionId(customTabId)
+
+ true
+ } else {
+ false
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabWindowFeature.kt b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabWindowFeature.kt
new file mode 100644
index 0000000000..77aa6f0999
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabWindowFeature.kt
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.customtabs
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import androidx.annotation.VisibleForTesting
+import androidx.annotation.VisibleForTesting.Companion.PRIVATE
+import androidx.browser.customtabs.CustomTabColorSchemeParams
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.core.net.toUri
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import kotlinx.coroutines.flow.mapNotNull
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.selector.findCustomTab
+import mozilla.components.browser.state.state.CustomTabConfig
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.window.WindowRequest
+import mozilla.components.lib.state.ext.flowScoped
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+
+const val SHORTCUT_CATEGORY = "mozilla.components.pwa.category.SHORTCUT"
+
+/**
+ * Feature implementation for handling window requests by opening custom tabs.
+ */
+class CustomTabWindowFeature(
+ private val activity: Activity,
+ private val store: BrowserStore,
+ private val sessionId: String,
+) : LifecycleAwareFeature {
+
+ private var scope: CoroutineScope? = null
+
+ /**
+ * Transform a [CustomTabConfig] into a [CustomTabsIntent] that creates a
+ * new custom tab with the same styling and layout
+ */
+ @Suppress("ComplexMethod")
+ @VisibleForTesting(otherwise = PRIVATE)
+ internal fun configToIntent(config: CustomTabConfig?): CustomTabsIntent {
+ val intent = CustomTabsIntent.Builder().apply {
+ setInstantAppsEnabled(false)
+
+ val customTabColorSchemeBuilder = CustomTabColorSchemeParams.Builder()
+ config?.colorSchemes?.defaultColorSchemeParams?.toolbarColor?.let {
+ customTabColorSchemeBuilder.setToolbarColor(it)
+ }
+ config?.colorSchemes?.defaultColorSchemeParams?.navigationBarColor?.let {
+ customTabColorSchemeBuilder.setNavigationBarColor(it)
+ }
+ setDefaultColorSchemeParams(customTabColorSchemeBuilder.build())
+
+ if (config?.enableUrlbarHiding == true) setUrlBarHidingEnabled(true)
+ config?.closeButtonIcon?.let { setCloseButtonIcon(it) }
+ if (config?.showShareMenuItem == true) setShareState(CustomTabsIntent.SHARE_STATE_ON)
+ config?.titleVisible?.let { setShowTitle(it) }
+ config?.actionButtonConfig?.apply { setActionButton(icon, description, pendingIntent, tint) }
+ config?.menuItems?.forEach { addMenuItem(it.name, it.pendingIntent) }
+ }.build()
+
+ intent.intent.`package` = activity.packageName
+ intent.intent.addCategory(SHORTCUT_CATEGORY)
+
+ return intent
+ }
+
+ /**
+ * Starts observing the configured session to listen for window requests.
+ */
+ override fun start() {
+ scope = store.flowScoped { flow ->
+ flow.mapNotNull { state -> state.findCustomTab(sessionId) }
+ .distinctUntilChangedBy {
+ it.content.windowRequest
+ }
+ .collect { state ->
+ val windowRequest = state.content.windowRequest
+ if (windowRequest?.type == WindowRequest.Type.OPEN) {
+ val intent = configToIntent(state.config)
+ val uri = windowRequest.url.toUri()
+ // This could only fail if the above intent is for our application
+ // and we are not registered to handle its schemes.
+ try {
+ intent.launchUrl(activity, uri)
+ } catch (e: ActivityNotFoundException) {
+ // Workaround for unsupported schemes
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1878704
+ state.engineState.engineSession?.loadUrl(windowRequest.url)
+ }
+ store.dispatch(ContentAction.ConsumeWindowRequestAction(sessionId))
+ }
+ }
+ }
+ }
+
+ /**
+ * Stops observing the configured session for incoming window requests.
+ */
+ override fun stop() {
+ scope?.cancel()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabsFacts.kt b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabsFacts.kt
new file mode 100644
index 0000000000..6be0cb9610
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabsFacts.kt
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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.customtabs
+
+import mozilla.components.support.base.Component
+import mozilla.components.support.base.facts.Action
+import mozilla.components.support.base.facts.Fact
+import mozilla.components.support.base.facts.collect
+
+/**
+ * Facts emitted for telemetry related to [CustomTabsToolbarFeature]
+ */
+class CustomTabsFacts {
+ /**
+ * Items that specify which portion of the [CustomTabsToolbarFeature] was interacted with
+ */
+ object Items {
+ const val CLOSE = "close"
+ const val ACTION_BUTTON = "action_button"
+ }
+}
+
+private fun emitCustomTabsFact(
+ action: Action,
+ item: String,
+) {
+ Fact(
+ Component.FEATURE_CUSTOMTABS,
+ action,
+ item,
+ ).collect()
+}
+
+internal fun emitCloseFact() = emitCustomTabsFact(Action.CLICK, CustomTabsFacts.Items.CLOSE)
+internal fun emitActionButtonFact() = emitCustomTabsFact(Action.CLICK, CustomTabsFacts.Items.ACTION_BUTTON)
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabsToolbarFeature.kt b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabsToolbarFeature.kt
new file mode 100644
index 0000000000..f437a6874d
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/CustomTabsToolbarFeature.kt
@@ -0,0 +1,425 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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.customtabs
+
+import android.app.PendingIntent
+import android.app.UiModeManager.MODE_NIGHT_YES
+import android.content.Context
+import android.content.res.Configuration
+import android.graphics.Bitmap
+import android.util.Size
+import android.view.Window
+import androidx.annotation.ColorInt
+import androidx.annotation.VisibleForTesting
+import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
+import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO
+import androidx.appcompat.app.AppCompatDelegate.NightMode
+import androidx.appcompat.content.res.AppCompatResources.getDrawable
+import androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_DARK
+import androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_LIGHT
+import androidx.browser.customtabs.CustomTabsIntent.COLOR_SCHEME_SYSTEM
+import androidx.browser.customtabs.CustomTabsIntent.ColorScheme
+import androidx.core.content.ContextCompat.getColor
+import androidx.core.graphics.drawable.toDrawable
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.mapNotNull
+import mozilla.components.browser.menu.BrowserMenuBuilder
+import mozilla.components.browser.menu.BrowserMenuItem
+import mozilla.components.browser.menu.item.SimpleBrowserMenuItem
+import mozilla.components.browser.state.selector.findCustomTab
+import mozilla.components.browser.state.state.ColorSchemeParams
+import mozilla.components.browser.state.state.ColorSchemes
+import mozilla.components.browser.state.state.CustomTabActionButtonConfig
+import mozilla.components.browser.state.state.CustomTabConfig
+import mozilla.components.browser.state.state.CustomTabMenuItem
+import mozilla.components.browser.state.state.CustomTabSessionState
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.browser.toolbar.BrowserToolbar
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.feature.customtabs.feature.CustomTabSessionTitleObserver
+import mozilla.components.feature.customtabs.menu.sendWithUrl
+import mozilla.components.feature.tabs.CustomTabsUseCases
+import mozilla.components.lib.state.ext.flowScoped
+import mozilla.components.support.base.feature.LifecycleAwareFeature
+import mozilla.components.support.base.feature.UserInteractionHandler
+import mozilla.components.support.ktx.android.content.res.resolveAttribute
+import mozilla.components.support.ktx.android.content.share
+import mozilla.components.support.ktx.android.util.dpToPx
+import mozilla.components.support.ktx.android.view.setNavigationBarTheme
+import mozilla.components.support.ktx.android.view.setStatusBarTheme
+import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged
+import mozilla.components.support.utils.ColorUtils.getReadableTextColor
+import mozilla.components.support.utils.ext.resizeMaintainingAspectRatio
+import mozilla.components.ui.icons.R as iconsR
+
+/**
+ * Initializes and resets the [BrowserToolbar] for a Custom Tab based on the [CustomTabConfig].
+ *
+ * @property store The given [BrowserStore] to use.
+ * @property toolbar Reference to the [BrowserToolbar], so that the color and menu items can be set.
+ * @property sessionId ID of the custom tab session. No-op if null or invalid.
+ * @property useCases The given [CustomTabsUseCases] to use.
+ * @property menuBuilder [BrowserMenuBuilder] reference to pull menu options from.
+ * @property menuItemIndex Location to insert any custom menu options into the predefined menu list.
+ * @property window Reference to the [Window] so the navigation bar color can be set.
+ * @property updateTheme Whether or not the toolbar and system bar colors should be changed.
+ * @property appNightMode The [NightMode] used in the app. Defaults to [MODE_NIGHT_FOLLOW_SYSTEM].
+ * @property forceActionButtonTinting When set to true the [toolbar] action button will always be tinted
+ * based on the [toolbar] background, ignoring the value of [CustomTabActionButtonConfig.tint].
+ * @property isNavBarEnabled Whether or not the navigation bar is enabled.
+ * @property shareListener Invoked when the share button is pressed.
+ * @property closeListener Invoked when the close button is pressed.
+ */
+@Suppress("LargeClass")
+class CustomTabsToolbarFeature(
+ private val store: BrowserStore,
+ private val toolbar: BrowserToolbar,
+ private val sessionId: String? = null,
+ private val useCases: CustomTabsUseCases,
+ private val menuBuilder: BrowserMenuBuilder? = null,
+ private val menuItemIndex: Int = menuBuilder?.items?.size ?: 0,
+ private val window: Window? = null,
+ private val updateTheme: Boolean = true,
+ @NightMode private val appNightMode: Int = MODE_NIGHT_FOLLOW_SYSTEM,
+ private val forceActionButtonTinting: Boolean = false,
+ private val isNavBarEnabled: Boolean = false,
+ private val shareListener: (() -> Unit)? = null,
+ private val closeListener: () -> Unit,
+) : LifecycleAwareFeature, UserInteractionHandler {
+ private var initialized: Boolean = false
+ private val titleObserver = CustomTabSessionTitleObserver(toolbar)
+ private val context get() = toolbar.context
+ private var scope: CoroutineScope? = null
+
+ /**
+ * Gets the current custom tab session.
+ */
+ private val session: CustomTabSessionState?
+ get() = sessionId?.let { store.state.findCustomTab(it) }
+
+ /**
+ * Initializes the feature and registers the [CustomTabSessionTitleObserver].
+ */
+ override fun start() {
+ val tabId = sessionId ?: return
+ val tab = store.state.findCustomTab(tabId) ?: return
+
+ scope = store.flowScoped { flow ->
+ flow
+ .mapNotNull { state -> state.findCustomTab(tabId) }
+ .ifAnyChanged { tab -> arrayOf(tab.content.title, tab.content.url) }
+ .collect { tab -> titleObserver.onTab(tab) }
+ }
+
+ if (!initialized) {
+ initialized = true
+ init(tab.config)
+ }
+ }
+
+ /**
+ * Unregisters the [CustomTabSessionTitleObserver].
+ */
+ override fun stop() {
+ scope?.cancel()
+ }
+
+ @VisibleForTesting
+ internal fun init(config: CustomTabConfig) {
+ // Don't allow clickable toolbar so a custom tab can't switch to edit mode.
+ toolbar.display.onUrlClicked = { false }
+ toolbar.display.hidePageActionSeparator()
+
+ // Use the intent provided color scheme or fallback to the app night mode preference.
+ val nightMode = config.colorScheme?.toNightMode() ?: appNightMode
+
+ val colorSchemeParams = config.colorSchemes?.getConfiguredColorSchemeParams(
+ nightMode = nightMode,
+ isDarkMode = context.isDarkMode(),
+ )
+
+ val readableColor = if (updateTheme) {
+ colorSchemeParams?.toolbarColor?.let { getReadableTextColor(it) }
+ ?: toolbar.display.colors.menu
+ } else {
+ // It's private mode, the readable color needs match the app.
+ // Note: The main app is configuring the private theme, Custom Tabs is adding the
+ // additional theming for the dynamic UI elements e.g. action & share buttons.
+ val colorResId = context.theme.resolveAttribute(android.R.attr.textColorPrimary)
+ getColor(context, colorResId)
+ }
+
+ if (updateTheme) {
+ colorSchemeParams.let {
+ updateTheme(
+ toolbarColor = it?.toolbarColor,
+ navigationBarColor = it?.navigationBarColor ?: it?.toolbarColor,
+ navigationBarDividerColor = it?.navigationBarDividerColor,
+ readableColor = readableColor,
+ )
+ }
+ }
+
+ // Add navigation close action
+ if (config.showCloseButton) {
+ addCloseButton(readableColor, config.closeButtonIcon)
+ }
+
+ // Add action button
+ addActionButton(readableColor, config.actionButtonConfig)
+
+ // Show share button
+ if (config.showShareMenuItem) {
+ addShareButton(readableColor)
+ }
+
+ // Add menu items
+ if (config.menuItems.isNotEmpty() || menuBuilder?.items?.isNotEmpty() == true) {
+ addMenuItems(config.menuItems, menuItemIndex)
+ }
+
+ if (isNavBarEnabled) {
+ toolbar.display.hideMenuButton()
+ }
+ }
+
+ @VisibleForTesting
+ internal fun updateTheme(
+ @ColorInt toolbarColor: Int? = null,
+ @ColorInt navigationBarColor: Int? = null,
+ @ColorInt navigationBarDividerColor: Int? = null,
+ @ColorInt readableColor: Int,
+ ) {
+ toolbarColor?.let {
+ toolbar.setBackgroundColor(it)
+
+ toolbar.display.colors = toolbar.display.colors.copy(
+ text = readableColor,
+ title = readableColor,
+ securityIconSecure = readableColor,
+ securityIconInsecure = readableColor,
+ trackingProtection = readableColor,
+ menu = readableColor,
+ )
+
+ window?.setStatusBarTheme(it)
+ }
+
+ if (navigationBarColor != null || navigationBarDividerColor != null) {
+ window?.setNavigationBarTheme(navigationBarColor, navigationBarDividerColor)
+ }
+ }
+
+ /**
+ * Display a close button at the start of the toolbar.
+ * When clicked, it calls [closeListener].
+ */
+ @VisibleForTesting
+ internal fun addCloseButton(@ColorInt readableColor: Int, bitmap: Bitmap?) {
+ val drawableIcon = bitmap?.toDrawable(context.resources)
+ ?: getDrawable(context, iconsR.drawable.mozac_ic_cross_24)!!.mutate()
+
+ drawableIcon.setTint(readableColor)
+
+ val button = Toolbar.ActionButton(
+ drawableIcon,
+ context.getString(R.string.mozac_feature_customtabs_exit_button),
+ ) {
+ emitCloseFact()
+ session?.let {
+ useCases.remove(it.id)
+ }
+ closeListener.invoke()
+ }
+ toolbar.addNavigationAction(button)
+ }
+
+ /**
+ * Display an action button from the custom tab config on the toolbar.
+ * When clicked, it activates the corresponding [PendingIntent].
+ */
+ @VisibleForTesting
+ internal fun addActionButton(
+ @ColorInt readableColor: Int,
+ buttonConfig: CustomTabActionButtonConfig?,
+ ) {
+ buttonConfig?.let { config ->
+ val icon = config.icon
+ val scaledIconSize = icon.resizeMaintainingAspectRatio(ACTION_BUTTON_MAX_DRAWABLE_DP_SIZE)
+ val drawableIcon = Bitmap.createScaledBitmap(
+ icon,
+ scaledIconSize.width.dpToPx(context.resources.displayMetrics),
+ scaledIconSize.height.dpToPx(context.resources.displayMetrics),
+ true,
+ ).toDrawable(context.resources)
+
+ if (config.tint || forceActionButtonTinting) {
+ drawableIcon.setTint(readableColor)
+ }
+
+ val button = Toolbar.ActionButton(
+ drawableIcon,
+ config.description,
+ ) {
+ emitActionButtonFact()
+ session?.let {
+ config.pendingIntent.sendWithUrl(context, it.content.url)
+ }
+ }
+
+ toolbar.addBrowserAction(button)
+ }
+ }
+
+ /**
+ * Display a share button as a button on the toolbar.
+ * When clicked, it activates [shareListener] and defaults to the [share] KTX helper.
+ */
+ @VisibleForTesting
+ internal fun addShareButton(@ColorInt readableColor: Int) {
+ val drawableIcon = getDrawable(context, iconsR.drawable.mozac_ic_share_android_24)!!
+ drawableIcon.setTint(readableColor)
+
+ val button = Toolbar.ActionButton(
+ drawableIcon,
+ context.getString(R.string.mozac_feature_customtabs_share_link),
+ ) {
+ val listener = shareListener ?: {
+ session?.let {
+ context.share(it.content.url)
+ }
+ }
+ emitActionButtonFact()
+ listener.invoke()
+ }
+
+ toolbar.addBrowserAction(button)
+ }
+
+ /**
+ * Build the menu items displayed when the 3-dot overflow menu is opened.
+ */
+ @VisibleForTesting
+ internal fun addMenuItems(
+ menuItems: List<CustomTabMenuItem>,
+ index: Int,
+ ) {
+ menuItems.map { item ->
+ SimpleBrowserMenuItem(item.name) {
+ session?.let {
+ item.pendingIntent.sendWithUrl(context, it.content.url)
+ }
+ }
+ }.also { items ->
+ val combinedItems = menuBuilder?.let { builder ->
+ val newMenuItemList = mutableListOf<BrowserMenuItem>()
+ val insertIndex = index.coerceIn(0, builder.items.size)
+
+ newMenuItemList.apply {
+ addAll(builder.items)
+ addAll(insertIndex, items)
+ }
+ } ?: items
+
+ val combinedExtras = menuBuilder?.let { builder ->
+ builder.extras + Pair("customTab", true)
+ }
+
+ toolbar.display.menuBuilder = BrowserMenuBuilder(combinedItems, combinedExtras.orEmpty())
+ }
+ }
+
+ /**
+ * When the back button is pressed if not initialized returns false,
+ * when initialized removes the current Custom Tabs session and returns true.
+ * Should be called when the back button is pressed.
+ */
+ override fun onBackPressed(): Boolean {
+ return if (!initialized) {
+ false
+ } else {
+ if (sessionId != null && useCases.remove(sessionId)) {
+ closeListener.invoke()
+ true
+ } else {
+ false
+ }
+ }
+ }
+
+ companion object {
+ private val ACTION_BUTTON_MAX_DRAWABLE_DP_SIZE = Size(48, 24)
+ }
+}
+
+@VisibleForTesting
+internal fun ColorSchemes.getConfiguredColorSchemeParams(
+ @NightMode nightMode: Int? = null,
+ isDarkMode: Boolean = false,
+) = when {
+ noColorSchemeParamsSet() -> null
+
+ defaultColorSchemeParamsOnly() -> defaultColorSchemeParams
+
+ // Try to follow specified color scheme.
+ nightMode == MODE_NIGHT_FOLLOW_SYSTEM -> {
+ if (isDarkMode) {
+ darkColorSchemeParams?.withDefault(defaultColorSchemeParams)
+ ?: defaultColorSchemeParams
+ } else {
+ lightColorSchemeParams?.withDefault(defaultColorSchemeParams)
+ ?: defaultColorSchemeParams
+ }
+ }
+
+ nightMode == MODE_NIGHT_NO -> lightColorSchemeParams?.withDefault(
+ defaultColorSchemeParams,
+ ) ?: defaultColorSchemeParams
+
+ nightMode == MODE_NIGHT_YES -> darkColorSchemeParams?.withDefault(
+ defaultColorSchemeParams,
+ ) ?: defaultColorSchemeParams
+
+ // No color scheme set, try to use default.
+ else -> defaultColorSchemeParams
+}
+
+/**
+ * Try to convert the given [ColorScheme] to [NightMode].
+ */
+@VisibleForTesting
+@NightMode
+internal fun Int.toNightMode() = when (this) {
+ COLOR_SCHEME_SYSTEM -> MODE_NIGHT_FOLLOW_SYSTEM
+ COLOR_SCHEME_LIGHT -> MODE_NIGHT_NO
+ COLOR_SCHEME_DARK -> MODE_NIGHT_YES
+ else -> null
+}
+
+private fun ColorSchemes.noColorSchemeParamsSet() =
+ defaultColorSchemeParams == null && lightColorSchemeParams == null && darkColorSchemeParams == null
+
+private fun ColorSchemes.defaultColorSchemeParamsOnly() =
+ defaultColorSchemeParams != null && lightColorSchemeParams == null && darkColorSchemeParams == null
+
+private fun Context.isDarkMode() =
+ resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
+
+/**
+ * Try to create a [ColorSchemeParams] using the given [defaultColorSchemeParam] as a fallback if
+ * there are missing properties.
+ */
+@VisibleForTesting
+internal fun ColorSchemeParams.withDefault(defaultColorSchemeParam: ColorSchemeParams?) = ColorSchemeParams(
+ toolbarColor = toolbarColor
+ ?: defaultColorSchemeParam?.toolbarColor,
+ secondaryToolbarColor = secondaryToolbarColor
+ ?: defaultColorSchemeParam?.secondaryToolbarColor,
+ navigationBarColor = navigationBarColor
+ ?: defaultColorSchemeParam?.navigationBarColor,
+ navigationBarDividerColor = navigationBarDividerColor
+ ?: defaultColorSchemeParam?.navigationBarDividerColor,
+)
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/feature/CustomTabSessionTitleObserver.kt b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/feature/CustomTabSessionTitleObserver.kt
new file mode 100644
index 0000000000..446542030a
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/feature/CustomTabSessionTitleObserver.kt
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.customtabs.feature
+
+import mozilla.components.browser.state.state.CustomTabSessionState
+import mozilla.components.concept.toolbar.Toolbar
+
+/**
+ * Sets the title of the custom tab toolbar based on the session title and URL.
+ */
+class CustomTabSessionTitleObserver(
+ private val toolbar: Toolbar,
+) {
+ private var url: String? = null
+ private var title: String? = null
+ private var showedTitle = false
+
+ internal fun onTab(tab: CustomTabSessionState) {
+ if (tab.content.title != title) {
+ onTitleChanged(tab)
+ title = tab.content.title
+ }
+
+ if (tab.content.url != url) {
+ onUrlChanged(tab)
+ url = tab.content.url
+ }
+ }
+
+ private fun onUrlChanged(tab: CustomTabSessionState) {
+ // If we showed a title once in a custom tab then we are going to continue displaying
+ // a title (to avoid the layout bouncing around). However if no title is available then
+ // we just use the URL.
+ if (showedTitle && tab.content.title.isEmpty()) {
+ toolbar.title = tab.content.url
+ }
+ }
+
+ private fun onTitleChanged(tab: CustomTabSessionState) {
+ if (tab.content.title.isNotEmpty()) {
+ toolbar.title = tab.content.title
+ showedTitle = true
+ } else if (showedTitle) {
+ // See comment in OnUrlChanged().
+ toolbar.title = tab.content.url
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/feature/OriginVerifierFeature.kt b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/feature/OriginVerifierFeature.kt
new file mode 100644
index 0000000000..d92c26cda0
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/feature/OriginVerifierFeature.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.feature.customtabs.feature
+
+import android.content.pm.PackageManager
+import android.net.Uri
+import androidx.annotation.VisibleForTesting
+import androidx.browser.customtabs.CustomTabsService.Relation
+import androidx.browser.customtabs.CustomTabsSessionToken
+import mozilla.components.feature.customtabs.store.CustomTabState
+import mozilla.components.feature.customtabs.store.CustomTabsAction
+import mozilla.components.feature.customtabs.store.OriginRelationPair
+import mozilla.components.feature.customtabs.store.ValidateRelationshipAction
+import mozilla.components.feature.customtabs.store.VerificationStatus.FAILURE
+import mozilla.components.feature.customtabs.store.VerificationStatus.PENDING
+import mozilla.components.feature.customtabs.store.VerificationStatus.SUCCESS
+import mozilla.components.feature.customtabs.verify.OriginVerifier
+import mozilla.components.service.digitalassetlinks.RelationChecker
+
+class OriginVerifierFeature(
+ private val packageManager: PackageManager,
+ private val relationChecker: RelationChecker,
+ private val dispatch: (CustomTabsAction) -> Unit,
+) {
+
+ private var cachedVerifier: Triple<String, Int, OriginVerifier>? = null
+
+ suspend fun verify(
+ state: CustomTabState,
+ token: CustomTabsSessionToken,
+ @Relation relation: Int,
+ origin: Uri,
+ ): Boolean {
+ val packageName = state.creatorPackageName ?: return false
+
+ val existingRelation = state.relationships[OriginRelationPair(origin, relation)]
+ return if (existingRelation == SUCCESS || existingRelation == FAILURE) {
+ // Return if relation is already success or failure
+ existingRelation == SUCCESS
+ } else {
+ val verifier = getVerifier(packageName, relation)
+ dispatch(ValidateRelationshipAction(token, relation, origin, PENDING))
+
+ val result = verifier.verifyOrigin(origin)
+ val status = if (result) SUCCESS else FAILURE
+
+ dispatch(ValidateRelationshipAction(token, relation, origin, status))
+ result
+ }
+ }
+
+ @VisibleForTesting
+ internal fun getVerifier(packageName: String, @Relation relation: Int): OriginVerifier {
+ cachedVerifier?.let {
+ val (cachedPackage, cachedRelation, verifier) = it
+ if (cachedPackage == packageName && cachedRelation == relation) {
+ return verifier
+ }
+ }
+
+ return OriginVerifier(packageName, relation, packageManager, relationChecker).also {
+ cachedVerifier = Triple(packageName, relation, it)
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/menu/CustomTabMenuCandidates.kt b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/menu/CustomTabMenuCandidates.kt
new file mode 100644
index 0000000000..07bf60b119
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/menu/CustomTabMenuCandidates.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.feature.customtabs.menu
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.core.net.toUri
+import mozilla.components.browser.state.state.CustomTabSessionState
+import mozilla.components.concept.menu.candidate.TextMenuCandidate
+
+/**
+ * Build menu items displayed when the 3-dot overflow menu is opened.
+ * These items are provided by the app that creates the custom tab,
+ * and should be inserted alongside menu items created by the browser.
+ */
+fun CustomTabSessionState.createCustomTabMenuCandidates(context: Context) =
+ config.menuItems.map { item ->
+ TextMenuCandidate(
+ text = item.name,
+ ) {
+ item.pendingIntent.sendWithUrl(context, content.url)
+ }
+ }
+
+internal fun PendingIntent.sendWithUrl(context: Context, url: String) = send(
+ context,
+ 0,
+ Intent(null, url.toUri()),
+)
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/store/CustomTabsAction.kt b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/store/CustomTabsAction.kt
new file mode 100644
index 0000000000..5c9e6941e6
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/store/CustomTabsAction.kt
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.customtabs.store
+
+import android.net.Uri
+import androidx.browser.customtabs.CustomTabsService.Relation
+import androidx.browser.customtabs.CustomTabsSessionToken
+import mozilla.components.lib.state.Action
+
+sealed class CustomTabsAction : Action {
+ abstract val token: CustomTabsSessionToken
+}
+
+/**
+ * Saves the package name corresponding to a custom tab token.
+ *
+ * @property token Token of the custom tab.
+ * @property packageName Package name of the app that created the custom tab.
+ */
+data class SaveCreatorPackageNameAction(
+ override val token: CustomTabsSessionToken,
+ val packageName: String,
+) : CustomTabsAction()
+
+/**
+ * Marks the state of a custom tabs [Relation] verification.
+ *
+ * @property token Token of the custom tab to verify.
+ * @property relation Relationship type to verify.
+ * @property origin Origin to verify.
+ * @property status State of the verification process.
+ */
+data class ValidateRelationshipAction(
+ override val token: CustomTabsSessionToken,
+ @Relation val relation: Int,
+ val origin: Uri,
+ val status: VerificationStatus,
+) : CustomTabsAction()
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/store/CustomTabsServiceState.kt b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/store/CustomTabsServiceState.kt
new file mode 100644
index 0000000000..e95b4262ce
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/store/CustomTabsServiceState.kt
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.customtabs.store
+
+import android.net.Uri
+import androidx.browser.customtabs.CustomTabsService
+import androidx.browser.customtabs.CustomTabsSessionToken
+import mozilla.components.lib.state.State
+
+/**
+ * Value type that represents the custom tabs state
+ * accessible from both the service and activity.
+ */
+data class CustomTabsServiceState(
+ val tabs: Map<CustomTabsSessionToken, CustomTabState> = emptyMap(),
+) : State
+
+/**
+ * Value type that represents the state of a single custom tab
+ * accessible from both the service and activity.
+ *
+ * This data is meant to supplement [mozilla.components.browser.session.tab.CustomTabConfig],
+ * not replace it. It only contains data that the service also needs to work with.
+ *
+ * @property creatorPackageName Package name of the app that created the custom tab.
+ * @property relationships Map of origin and relationship type to current verification state.
+ */
+data class CustomTabState(
+ val creatorPackageName: String? = null,
+ val relationships: Map<OriginRelationPair, VerificationStatus> = emptyMap(),
+)
+
+/**
+ * Pair of origin and relation type used as key in [CustomTabState.relationships].
+ *
+ * @property origin URL that contains only the scheme, host, and port.
+ * https://html.spec.whatwg.org/multipage/origin.html#concept-origin
+ * @property relation Enum that indicates the relation type.
+ */
+data class OriginRelationPair(
+ val origin: Uri,
+ @CustomTabsService.Relation val relation: Int,
+)
+
+/**
+ * Different states of Digital Asset Link verification.
+ */
+enum class VerificationStatus {
+ /**
+ * Indicates verification has started and hasn't returned yet.
+ *
+ * To avoid flashing the toolbar, we choose to hide it when a Digital Asset Link is being verified.
+ * We only show the toolbar when the verification fails, or an origin never requested to be verified.
+ */
+ PENDING,
+
+ /**
+ * Indicates that verification has completed and the link was verified.
+ */
+ SUCCESS,
+
+ /**
+ * Indicates that verification has completed and the link was invalid.
+ */
+ FAILURE,
+}
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/store/CustomTabsServiceStateReducer.kt b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/store/CustomTabsServiceStateReducer.kt
new file mode 100644
index 0000000000..13ee59c934
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/store/CustomTabsServiceStateReducer.kt
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.customtabs.store
+
+internal object CustomTabsServiceStateReducer {
+
+ fun reduce(state: CustomTabsServiceState, action: CustomTabsAction): CustomTabsServiceState {
+ val tabState = state.tabs.getOrElse(action.token) { CustomTabState() }
+ val newTabState = reduceTab(tabState, action)
+ return state.copy(tabs = state.tabs + Pair(action.token, newTabState))
+ }
+
+ private fun reduceTab(state: CustomTabState, action: CustomTabsAction): CustomTabState {
+ return when (action) {
+ is SaveCreatorPackageNameAction ->
+ state.copy(creatorPackageName = action.packageName)
+ is ValidateRelationshipAction ->
+ state.copy(
+ relationships = state.relationships + Pair(
+ OriginRelationPair(action.origin, action.relation),
+ action.status,
+ ),
+ )
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/store/CustomTabsServiceStore.kt b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/store/CustomTabsServiceStore.kt
new file mode 100644
index 0000000000..e0b9ba5b86
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/store/CustomTabsServiceStore.kt
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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.customtabs.store
+
+import mozilla.components.lib.state.Store
+
+class CustomTabsServiceStore(
+ initialState: CustomTabsServiceState = CustomTabsServiceState(),
+) : Store<CustomTabsServiceState, CustomTabsAction>(
+ initialState,
+ CustomTabsServiceStateReducer::reduce,
+ threadNamePrefix = "CustomTabsService",
+)
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/verify/OriginVerifier.kt b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/verify/OriginVerifier.kt
new file mode 100644
index 0000000000..bcf4f34541
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/java/mozilla/components/feature/customtabs/verify/OriginVerifier.kt
@@ -0,0 +1,76 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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.customtabs.verify
+
+import android.content.pm.PackageManager
+import android.net.Uri
+import androidx.annotation.VisibleForTesting
+import androidx.browser.customtabs.CustomTabsService.RELATION_HANDLE_ALL_URLS
+import androidx.browser.customtabs.CustomTabsService.RELATION_USE_AS_ORIGIN
+import androidx.browser.customtabs.CustomTabsService.Relation
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.withContext
+import mozilla.components.concept.fetch.Client
+import mozilla.components.service.digitalassetlinks.AndroidAssetFinder
+import mozilla.components.service.digitalassetlinks.AssetDescriptor
+import mozilla.components.service.digitalassetlinks.Relation.HANDLE_ALL_URLS
+import mozilla.components.service.digitalassetlinks.Relation.USE_AS_ORIGIN
+import mozilla.components.service.digitalassetlinks.RelationChecker
+
+/**
+ * Used to verify postMessage origin for a designated package name.
+ *
+ * Uses Digital Asset Links to confirm that the given origin is associated with the package name.
+ * It caches any origin that has been verified during the current application
+ * lifecycle and reuses that without making any new network requests.
+ */
+class OriginVerifier(
+ private val packageName: String,
+ @Relation private val relation: Int,
+ packageManager: PackageManager,
+ private val relationChecker: RelationChecker,
+) {
+
+ @VisibleForTesting
+ internal val androidAsset by lazy {
+ AndroidAssetFinder().getAndroidAppAsset(packageName, packageManager).firstOrNull()
+ }
+
+ /**
+ * Verify the claimed origin for the cached package name asynchronously. This will end up
+ * making a network request for non-cached origins with a HTTP [Client].
+ *
+ * @param origin The postMessage origin the application is claiming to have. Can't be null.
+ */
+ suspend fun verifyOrigin(origin: Uri) = withContext(IO) { verifyOriginInternal(origin) }
+
+ @Suppress("ReturnCount")
+ private fun verifyOriginInternal(origin: Uri): Boolean {
+ val cachedOrigin = cachedOriginMap[packageName]
+ if (cachedOrigin == origin) return true
+
+ if (origin.scheme != "https") return false
+ val relationship = when (relation) {
+ RELATION_USE_AS_ORIGIN -> USE_AS_ORIGIN
+ RELATION_HANDLE_ALL_URLS -> HANDLE_ALL_URLS
+ else -> return false
+ }
+
+ val originVerified = relationChecker.checkRelationship(
+ source = AssetDescriptor.Web(site = origin.toString()),
+ target = androidAsset ?: return false,
+ relation = relationship,
+ )
+
+ if (originVerified && packageName !in cachedOriginMap) {
+ cachedOriginMap[packageName] = origin
+ }
+ return originVerified
+ }
+
+ companion object {
+ private val cachedOriginMap = mutableMapOf<String, Uri>()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-am/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-am/strings.xml
new file mode 100644
index 0000000000..c7214823df
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-am/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">ወደ ቀዳሚው መተግበሪያ ተመለስ</string>
+ <string name="mozac_feature_customtabs_share_link">አገናኝ አጋራ</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-an/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-an/strings.xml
new file mode 100644
index 0000000000..e0e79c0ca1
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-an/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Tornar ta l’aplicación anterior</string>
+ <string name="mozac_feature_customtabs_share_link">Compartir lo vinclo</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ar/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ar/strings.xml
new file mode 100644
index 0000000000..6815ac4db3
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ar/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">ارجع للتطبيق السابق</string>
+ <string name="mozac_feature_customtabs_share_link">شارِك الرابط</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ast/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ast/strings.xml
new file mode 100644
index 0000000000..20a0e7e9fe
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ast/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Volver a l\'aplicación anterior</string>
+ <string name="mozac_feature_customtabs_share_link">Compartir l\'enllaz</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-az/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-az/strings.xml
new file mode 100644
index 0000000000..b225d20e41
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-az/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Əvvəlki tətbiqə qayıt</string>
+ <string name="mozac_feature_customtabs_share_link">Keçidi paylaş</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-azb/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-azb/strings.xml
new file mode 100644
index 0000000000..deac72bfb9
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-azb/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">اؤنجه‌کی اَپَه دؤن</string>
+ <string name="mozac_feature_customtabs_share_link">باغلانتی‌نی پایلاش</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ban/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ban/strings.xml
new file mode 100644
index 0000000000..b990cefbab
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ban/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Uliang ka aplikasi sadurungnyane</string>
+ <string name="mozac_feature_customtabs_share_link">Ngbagiang tautan</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-be/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-be/strings.xml
new file mode 100644
index 0000000000..fee7305095
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-be/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Вярнуцца ў папярэднюю праграму</string>
+ <string name="mozac_feature_customtabs_share_link">Падзяліцца спасылкай</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-bg/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-bg/strings.xml
new file mode 100644
index 0000000000..967d209d75
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-bg/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Връщане към предишното приложение</string>
+ <string name="mozac_feature_customtabs_share_link">Споделяне на препратка</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-bn/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-bn/strings.xml
new file mode 100644
index 0000000000..4676782818
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-bn/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">আগের অ্যাপে ফিরে যান</string>
+ <string name="mozac_feature_customtabs_share_link">লিংক শেয়ার করুন</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-br/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-br/strings.xml
new file mode 100644
index 0000000000..7e77e03600
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-br/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Distreiñ dʼan arload kent</string>
+ <string name="mozac_feature_customtabs_share_link">Rannañ an ere</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-bs/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-bs/strings.xml
new file mode 100644
index 0000000000..f9624f2eaa
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-bs/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Povratak na prethodnu aplikaciju</string>
+ <string name="mozac_feature_customtabs_share_link">Podijeli link</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ca/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ca/strings.xml
new file mode 100644
index 0000000000..e66e0ab37e
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ca/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Torna a l’aplicació anterior</string>
+ <string name="mozac_feature_customtabs_share_link">Comparteix l’enllaç</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-cak/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-cak/strings.xml
new file mode 100644
index 0000000000..47a7f2d06b
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-cak/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Titzolin pa ri jun chokoy</string>
+ <string name="mozac_feature_customtabs_share_link">Tikomonïx ri ximonel</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ceb/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ceb/strings.xml
new file mode 100644
index 0000000000..3a63483dab
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ceb/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Balik sa previous nga app</string>
+ <string name="mozac_feature_customtabs_share_link">i-Share ang link</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ckb/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ckb/strings.xml
new file mode 100644
index 0000000000..78ded2fc7b
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ckb/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">بگەڕێوە بۆ بەرنامەی پێشوو</string>
+ <string name="mozac_feature_customtabs_share_link">بەستەر بڵاوبکەرەوە</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-co/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-co/strings.xml
new file mode 100644
index 0000000000..912e8712fd
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-co/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Rivene à l’appiecazione precedente</string>
+ <string name="mozac_feature_customtabs_share_link">Sparte u liame</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-cs/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-cs/strings.xml
new file mode 100644
index 0000000000..0a6027a0ec
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-cs/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Návrat do předchozí aplikace</string>
+ <string name="mozac_feature_customtabs_share_link">Sdílet odkaz</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-cy/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-cy/strings.xml
new file mode 100644
index 0000000000..04e4257505
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-cy/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Nôl i’r ap blaenorol</string>
+ <string name="mozac_feature_customtabs_share_link">Rhannu dolen</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-da/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-da/strings.xml
new file mode 100644
index 0000000000..52264f69a0
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-da/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Tilbage til forrige app</string>
+ <string name="mozac_feature_customtabs_share_link">Del link</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-de/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-de/strings.xml
new file mode 100644
index 0000000000..a061bc7ba2
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-de/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Zurück zur vorherigen App</string>
+ <string name="mozac_feature_customtabs_share_link">Link teilen</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-dsb/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-dsb/strings.xml
new file mode 100644
index 0000000000..b6c295e334
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-dsb/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Slědk k pjerwjejšnemu nałoženjeju</string>
+ <string name="mozac_feature_customtabs_share_link">Wótkaz źěliś</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-el/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-el/strings.xml
new file mode 100644
index 0000000000..4f7ef1cc74
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-el/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Επιστροφή στην προηγούμενη εφαρμογή</string>
+ <string name="mozac_feature_customtabs_share_link">Κοινή χρήση συνδέσμου</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-en-rCA/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-en-rCA/strings.xml
new file mode 100644
index 0000000000..7d001b2872
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-en-rCA/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Return to previous app</string>
+ <string name="mozac_feature_customtabs_share_link">Share link</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-en-rGB/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-en-rGB/strings.xml
new file mode 100644
index 0000000000..7d001b2872
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-en-rGB/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Return to previous app</string>
+ <string name="mozac_feature_customtabs_share_link">Share link</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-eo/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-eo/strings.xml
new file mode 100644
index 0000000000..867203597e
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-eo/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Reen al antaŭa programo</string>
+ <string name="mozac_feature_customtabs_share_link">Kundividi ligilon</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-es-rAR/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-es-rAR/strings.xml
new file mode 100644
index 0000000000..ec4b3f66e2
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-es-rAR/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Volver a la aplicación anterior</string>
+ <string name="mozac_feature_customtabs_share_link">Compartir enlace</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-es-rCL/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-es-rCL/strings.xml
new file mode 100644
index 0000000000..8eda46d9e3
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-es-rCL/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Regresar a la aplicación anterior</string>
+ <string name="mozac_feature_customtabs_share_link">Compartir enlace</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-es-rES/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-es-rES/strings.xml
new file mode 100644
index 0000000000..ec4b3f66e2
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-es-rES/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Volver a la aplicación anterior</string>
+ <string name="mozac_feature_customtabs_share_link">Compartir enlace</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-es-rMX/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-es-rMX/strings.xml
new file mode 100644
index 0000000000..ec4b3f66e2
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-es-rMX/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Volver a la aplicación anterior</string>
+ <string name="mozac_feature_customtabs_share_link">Compartir enlace</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-es/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-es/strings.xml
new file mode 100644
index 0000000000..ec4b3f66e2
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-es/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Volver a la aplicación anterior</string>
+ <string name="mozac_feature_customtabs_share_link">Compartir enlace</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-et/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-et/strings.xml
new file mode 100644
index 0000000000..4b62fbc1be
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-et/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Tagasi eelmise äpi juurde</string>
+ <string name="mozac_feature_customtabs_share_link">Jaga linki</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-eu/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-eu/strings.xml
new file mode 100644
index 0000000000..0494ad16ad
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-eu/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Itzuli aurreko aplikaziora</string>
+ <string name="mozac_feature_customtabs_share_link">Partekatu lotura</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-fa/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-fa/strings.xml
new file mode 100644
index 0000000000..6a4589a70a
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-fa/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">بازگشت به برنامهٔ قبل</string>
+ <string name="mozac_feature_customtabs_share_link">اشتراک‌گذاری پیوند</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ff/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ff/strings.xml
new file mode 100644
index 0000000000..17aaa5ca70
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ff/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Rutto e jaaɓnirgal ɓennungal</string>
+ <string name="mozac_feature_customtabs_share_link">Lollin jokkol</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-fi/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-fi/strings.xml
new file mode 100644
index 0000000000..9eb780ce76
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-fi/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Palaa edelliseen sovellukseen</string>
+ <string name="mozac_feature_customtabs_share_link">Jaa linkki</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-fr/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-fr/strings.xml
new file mode 100644
index 0000000000..71e5ba49b7
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-fr/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Revenir à l’application précédente</string>
+ <string name="mozac_feature_customtabs_share_link">Partager le lien</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-fur/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-fur/strings.xml
new file mode 100644
index 0000000000..d0c99e5949
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-fur/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Torne ae aplicazion precedente</string>
+ <string name="mozac_feature_customtabs_share_link">Condivît link</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-fy-rNL/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-fy-rNL/strings.xml
new file mode 100644
index 0000000000..a34d8bc45f
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-fy-rNL/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Tebek nei foarige app</string>
+ <string name="mozac_feature_customtabs_share_link">Keppeling diele</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ga-rIE/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ga-rIE/strings.xml
new file mode 100644
index 0000000000..26d689932b
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ga-rIE/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Fill ar an aip roimhe seo</string>
+ <string name="mozac_feature_customtabs_share_link">Comhroinn an nasc</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-gd/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-gd/strings.xml
new file mode 100644
index 0000000000..f194676ccb
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-gd/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Till gun aplacaid roimhe</string>
+ <string name="mozac_feature_customtabs_share_link">Co-roinn an ceangal</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-gl/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-gl/strings.xml
new file mode 100644
index 0000000000..f3044f9fb5
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-gl/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Volver á aplicación anterior</string>
+ <string name="mozac_feature_customtabs_share_link">Compartir ligazón</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-gn/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-gn/strings.xml
new file mode 100644
index 0000000000..1e65bfac50
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-gn/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Ejevyjey tembiporu’i mboyveguávape</string>
+ <string name="mozac_feature_customtabs_share_link">Emoherakuã juajuha</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-gu-rIN/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-gu-rIN/strings.xml
new file mode 100644
index 0000000000..030d775962
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-gu-rIN/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">જુના એપમાં પાંછા જાઓ</string>
+ <string name="mozac_feature_customtabs_share_link">લિંક શેર કરો</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-hi-rIN/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-hi-rIN/strings.xml
new file mode 100644
index 0000000000..13a4031103
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-hi-rIN/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">पिछले ऐप पर वापस जाएं</string>
+ <string name="mozac_feature_customtabs_share_link">लिंक साझा करें</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-hr/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-hr/strings.xml
new file mode 100644
index 0000000000..b975cab3ac
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-hr/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Vrati se na prethodnu aplikaciju</string>
+ <string name="mozac_feature_customtabs_share_link">Dijeli poveznicu</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-hsb/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-hsb/strings.xml
new file mode 100644
index 0000000000..f4d842a363
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-hsb/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Wróćo k předchadnemu nałoženju</string>
+ <string name="mozac_feature_customtabs_share_link">Wotkaz dźělić</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-hu/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-hu/strings.xml
new file mode 100644
index 0000000000..ffee0390d9
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-hu/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Vissza az előző apphoz</string>
+ <string name="mozac_feature_customtabs_share_link">Hivatkozás megosztása</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-hy-rAM/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-hy-rAM/strings.xml
new file mode 100644
index 0000000000..11e13e8549
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-hy-rAM/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Վերադառնալ նախորդ հավելվածին</string>
+ <string name="mozac_feature_customtabs_share_link">Տարածել հղումը</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ia/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ia/strings.xml
new file mode 100644
index 0000000000..025c1f6fea
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ia/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Retornar al app previe</string>
+ <string name="mozac_feature_customtabs_share_link">Compartir le ligamine</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-in/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-in/strings.xml
new file mode 100644
index 0000000000..ba8bbc8887
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-in/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Kembali ke aplikasi sebelumnya</string>
+ <string name="mozac_feature_customtabs_share_link">Bagikan tautan</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-is/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-is/strings.xml
new file mode 100644
index 0000000000..8436de9ccc
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-is/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Farðu aftur í fyrra smáforrit</string>
+ <string name="mozac_feature_customtabs_share_link">Deila tengli</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-it/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-it/strings.xml
new file mode 100644
index 0000000000..bbb8597fab
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-it/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Torna all’applicazione precedente</string>
+ <string name="mozac_feature_customtabs_share_link">Condividi link</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-iw/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-iw/strings.xml
new file mode 100644
index 0000000000..bcb8ba7e7b
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-iw/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">חזרה ליישומון הקודם</string>
+ <string name="mozac_feature_customtabs_share_link">שיתוף קישור</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ja/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ja/strings.xml
new file mode 100644
index 0000000000..c032d9fca5
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ja/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">前のアプリへ戻る</string>
+ <string name="mozac_feature_customtabs_share_link">リンクを共有</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ka/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ka/strings.xml
new file mode 100644
index 0000000000..d1635e7825
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ka/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">წინა პროგრამაზე დაბრუნება</string>
+ <string name="mozac_feature_customtabs_share_link">ბმულის გაზიარება</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-kaa/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-kaa/strings.xml
new file mode 100644
index 0000000000..3282c0c294
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-kaa/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Aldınǵı baǵdarlamaǵa qaytıw</string>
+ <string name="mozac_feature_customtabs_share_link">Siltemeni bólisiw</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-kab/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-kab/strings.xml
new file mode 100644
index 0000000000..152805abb6
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-kab/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Uɣal ar usnas izrin</string>
+ <string name="mozac_feature_customtabs_share_link">Bḍu aseɣwen</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-kk/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-kk/strings.xml
new file mode 100644
index 0000000000..34c58c3238
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-kk/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Алдыңғы қолданбаға оралу</string>
+ <string name="mozac_feature_customtabs_share_link">Сілтемемен бөлісу</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-kmr/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-kmr/strings.xml
new file mode 100644
index 0000000000..25aca1dea7
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-kmr/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Vegere sepana berê</string>
+ <string name="mozac_feature_customtabs_share_link">Girêdanê parve bike</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-kn/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-kn/strings.xml
new file mode 100644
index 0000000000..8a851a3309
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-kn/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">ಹಿಂದಿನ ಅನ್ವಯಕ್ಕೆ ಮರಳಿ</string>
+ <string name="mozac_feature_customtabs_share_link">ಕೊಂಡಿಯನ್ನು ಹಂಚಿಕೊಳ್ಳಿ</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ko/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ko/strings.xml
new file mode 100644
index 0000000000..b41a66886d
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ko/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">이전 앱으로 돌아가기</string>
+ <string name="mozac_feature_customtabs_share_link">링크 공유</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-lij/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-lij/strings.xml
new file mode 100644
index 0000000000..dec5eaf455
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-lij/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Vanni a l\'app de primma</string>
+ <string name="mozac_feature_customtabs_share_link">Condividdi link</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-lo/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-lo/strings.xml
new file mode 100644
index 0000000000..5de508f665
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-lo/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">ກັບໄປຫາແອັບກ່ອນຫນ້ານີ້</string>
+ <string name="mozac_feature_customtabs_share_link">ແບ່ງປັນລີ້ງ</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-lt/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-lt/strings.xml
new file mode 100644
index 0000000000..61de1b3ecf
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-lt/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Grįžti į ankstesnę programą</string>
+ <string name="mozac_feature_customtabs_share_link">Dalintis saitu</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ml/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ml/strings.xml
new file mode 100644
index 0000000000..a1ee4e969f
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ml/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">മുമ്പത്തെ ആപ്പിലേക്ക് മടങ്ങുക</string>
+ <string name="mozac_feature_customtabs_share_link">കണ്ണി പങ്കിടുക</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-mr/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-mr/strings.xml
new file mode 100644
index 0000000000..4bacf2546d
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-mr/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">मागील अॅप वर परत या</string>
+ <string name="mozac_feature_customtabs_share_link">दुवा शेअर करा</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-my/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-my/strings.xml
new file mode 100644
index 0000000000..1084e61f08
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-my/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">အရင်ကအက်ပ်သို့ သွားပါ</string>
+ <string name="mozac_feature_customtabs_share_link">လင့်ခ်ကို မျှဝေရန်</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-nb-rNO/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-nb-rNO/strings.xml
new file mode 100644
index 0000000000..d319f8b77a
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-nb-rNO/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Gå tilbake til forrige app</string>
+ <string name="mozac_feature_customtabs_share_link">Del lenke</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ne-rNP/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ne-rNP/strings.xml
new file mode 100644
index 0000000000..9630785ed2
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ne-rNP/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">पहिलाको एपमा फर्किनुहोस्</string>
+ <string name="mozac_feature_customtabs_share_link">लिङ्क सेयर गर्नुहोस्</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-nl/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-nl/strings.xml
new file mode 100644
index 0000000000..6b0f757532
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-nl/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Terug naar vorige app</string>
+ <string name="mozac_feature_customtabs_share_link">Koppeling delen</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-nn-rNO/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-nn-rNO/strings.xml
new file mode 100644
index 0000000000..a815c4d553
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-nn-rNO/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Gå tilbake til førre app</string>
+ <string name="mozac_feature_customtabs_share_link">Del lenke</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-oc/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-oc/strings.xml
new file mode 100644
index 0000000000..01a0d4c645
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-oc/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Tornar a l’aplicacion precedenta</string>
+ <string name="mozac_feature_customtabs_share_link">Partejar lo ligam</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-or/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-or/strings.xml
new file mode 100644
index 0000000000..1a4925a2c1
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-or/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_share_link">ଲିଙ୍କ ବିତରଣ କରନ୍ତୁ</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-pa-rIN/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-pa-rIN/strings.xml
new file mode 100644
index 0000000000..9a2a887cab
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-pa-rIN/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">ਪਿਛਲੀ ਐਪ ‘ਤੇ ਜਾਓ</string>
+ <string name="mozac_feature_customtabs_share_link">ਲਿੰਕ ਸਾਂਝਾ ਕਰੋ</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-pa-rPK/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-pa-rPK/strings.xml
new file mode 100644
index 0000000000..431f8fa03f
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-pa-rPK/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">پچھلی ایپ نوں واپس جاؤ</string>
+ <string name="mozac_feature_customtabs_share_link">پتہ سانجھا کرو</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-pl/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-pl/strings.xml
new file mode 100644
index 0000000000..1cee15d909
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-pl/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Wróć do poprzedniej aplikacji</string>
+ <string name="mozac_feature_customtabs_share_link">Udostępnij odnośnik</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-pt-rBR/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-pt-rBR/strings.xml
new file mode 100644
index 0000000000..2838396787
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-pt-rBR/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Retornar ao aplicativo anterior</string>
+ <string name="mozac_feature_customtabs_share_link">Compartilhar link</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-pt-rPT/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-pt-rPT/strings.xml
new file mode 100644
index 0000000000..94edd3d2a2
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-pt-rPT/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Voltar à aplicação anterior</string>
+ <string name="mozac_feature_customtabs_share_link">Partilhar ligação</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-rm/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-rm/strings.xml
new file mode 100644
index 0000000000..5c8624edf4
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-rm/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Turnar a la app precedenta</string>
+ <string name="mozac_feature_customtabs_share_link">Cundivider la colliaziun</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ro/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ro/strings.xml
new file mode 100644
index 0000000000..f1bd593246
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ro/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Revenire la aplicația anterioară</string>
+ <string name="mozac_feature_customtabs_share_link">Partajează linkul</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ru/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ru/strings.xml
new file mode 100644
index 0000000000..bfdac1f591
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ru/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Вернуться к предыдущему приложению</string>
+ <string name="mozac_feature_customtabs_share_link">Поделиться ссылкой</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sat/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sat/strings.xml
new file mode 100644
index 0000000000..519b799596
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sat/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">ᱞᱟᱦᱟ ᱛᱮᱭᱟᱜ ᱮᱯ ᱨᱮ ᱨᱩᱣᱟᱹᱲᱚᱜ ᱢᱮ</string>
+ <string name="mozac_feature_customtabs_share_link">ᱞᱤᱝᱠ ᱦᱟᱹᱴᱤᱧ ᱢᱮ</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sc/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sc/strings.xml
new file mode 100644
index 0000000000..33b8dad9e6
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sc/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Torra a s’aplicatzione pretzedente</string>
+ <string name="mozac_feature_customtabs_share_link">Cumpartzi su ligòngiu</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-si/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-si/strings.xml
new file mode 100644
index 0000000000..f62e75a275
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-si/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">කලින් යෙදුමට ආපසු</string>
+ <string name="mozac_feature_customtabs_share_link">සබැඳිය බෙදාගන්න</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sk/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sk/strings.xml
new file mode 100644
index 0000000000..db1a9c2fef
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sk/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Návrat do predchádzajúcej aplikácie</string>
+ <string name="mozac_feature_customtabs_share_link">Zdieľať odkaz</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-skr/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-skr/strings.xml
new file mode 100644
index 0000000000..1336257ab3
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-skr/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">پچھلی ایپ تے واپس ون٘ڄو</string>
+ <string name="mozac_feature_customtabs_share_link">لنک شیئر کرو</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sl/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sl/strings.xml
new file mode 100644
index 0000000000..e71763b1ae
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sl/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Nazaj na prejšnjo aplikacijo</string>
+ <string name="mozac_feature_customtabs_share_link">Deli povezavo</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sq/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sq/strings.xml
new file mode 100644
index 0000000000..f616f9fee4
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sq/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Kthehu te aplikacioni i mëparshëm</string>
+ <string name="mozac_feature_customtabs_share_link">Ndani lidhje me të tjerët</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sr/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sr/strings.xml
new file mode 100644
index 0000000000..6fd3ffc4e4
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sr/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Врати се на претходну апликацију</string>
+ <string name="mozac_feature_customtabs_share_link">Подели везу</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-su/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-su/strings.xml
new file mode 100644
index 0000000000..6baec852bf
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-su/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Balik deui ka aplikasi saméméhna</string>
+ <string name="mozac_feature_customtabs_share_link">Bagikeun tutumbu</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sv-rSE/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sv-rSE/strings.xml
new file mode 100644
index 0000000000..ac83bc4426
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-sv-rSE/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Återgå till föregående app</string>
+ <string name="mozac_feature_customtabs_share_link">Dela länk</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ta/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ta/strings.xml
new file mode 100644
index 0000000000..1935e8b52e
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ta/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">முந்தைய செயலிக்குத் திரும்பு</string>
+ <string name="mozac_feature_customtabs_share_link">தொடுப்பைப் பகிர்</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-te/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-te/strings.xml
new file mode 100644
index 0000000000..cc2cfcd80b
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-te/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">మునుపటి అనువర్తనానికి తిరిగి వెళ్ళు</string>
+ <string name="mozac_feature_customtabs_share_link">లంకెను పంచుకోండి</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tg/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tg/strings.xml
new file mode 100644
index 0000000000..bc9afc0c8e
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tg/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Бозгашт ба барномаи қаблӣ</string>
+ <string name="mozac_feature_customtabs_share_link">Мубодила кардани пайванд</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-th/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-th/strings.xml
new file mode 100644
index 0000000000..177b490040
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-th/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">กลับไปที่แอปก่อนหน้า</string>
+ <string name="mozac_feature_customtabs_share_link">แบ่งปันลิงก์</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tl/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tl/strings.xml
new file mode 100644
index 0000000000..3d37950f8c
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tl/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Bumalik sa nakaraang app</string>
+ <string name="mozac_feature_customtabs_share_link">Ibahagi ang link</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tok/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tok/strings.xml
new file mode 100644
index 0000000000..ec2c84f263
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tok/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">o tawa ilo pini</string>
+ <string name="mozac_feature_customtabs_share_link">o pana e nimi nasin</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tr/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tr/strings.xml
new file mode 100644
index 0000000000..ec428ff885
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tr/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Önceki uygulamaya dön</string>
+ <string name="mozac_feature_customtabs_share_link">Bağlantıyı paylaş</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-trs/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-trs/strings.xml
new file mode 100644
index 0000000000..ffea9b71c2
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-trs/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Nānīkāj riña aplikasiûn garâjsunt akuan\'</string>
+ <string name="mozac_feature_customtabs_share_link">Dūyingô\' lînk</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tt/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tt/strings.xml
new file mode 100644
index 0000000000..68c4066531
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tt/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Соңгы кушымтага кире кайту</string>
+ <string name="mozac_feature_customtabs_share_link">Сылтаманы уртаклашу</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tzm/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tzm/strings.xml
new file mode 100644
index 0000000000..cece299be4
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-tzm/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_share_link">Bḍu asɣen</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ug/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ug/strings.xml
new file mode 100644
index 0000000000..6091e4f4e4
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ug/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">ئالدىنقى ئەپكە قايت</string>
+ <string name="mozac_feature_customtabs_share_link">ئۇلانمىنى ئورتاقلاش</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-uk/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-uk/strings.xml
new file mode 100644
index 0000000000..1b927f06ce
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-uk/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Повернутись до попередньої програми</string>
+ <string name="mozac_feature_customtabs_share_link">Поділитись посиланням</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ur/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ur/strings.xml
new file mode 100644
index 0000000000..f7ffc60127
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-ur/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">پچھلی ایپلیکیشن میں واپس جائیں</string>
+ <string name="mozac_feature_customtabs_share_link">ربط شیئر کریں</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-uz/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-uz/strings.xml
new file mode 100644
index 0000000000..76890b9883
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-uz/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Oldingi ilovaga qaytish</string>
+ <string name="mozac_feature_customtabs_share_link">Havolani ulashish</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-vec/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-vec/strings.xml
new file mode 100644
index 0000000000..ee9fe88ef3
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-vec/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Torna indrio a ƚ’aplicasione presedente</string>
+ <string name="mozac_feature_customtabs_share_link">Condividi link</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-vi/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-vi/strings.xml
new file mode 100644
index 0000000000..91dabe207a
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-vi/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Quay lại ứng dụng trước</string>
+ <string name="mozac_feature_customtabs_share_link">Chia sẻ liên kết</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-yo/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-yo/strings.xml
new file mode 100644
index 0000000000..793f44af04
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-yo/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Padà sí áàpù ti tẹ́lẹ̀</string>
+ <string name="mozac_feature_customtabs_share_link">Pín líǹkì</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-zh-rCN/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 0000000000..9bc538909f
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">返回之前的应用</string>
+ <string name="mozac_feature_customtabs_share_link">分享链接</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values-zh-rTW/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-zh-rTW/strings.xml
new file mode 100644
index 0000000000..fd1477504f
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values-zh-rTW/strings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">回到先前的應用程式</string>
+ <string name="mozac_feature_customtabs_share_link">分享鏈結</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values/dimens.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values/dimens.xml
new file mode 100644
index 0000000000..7ec404f03f
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values/dimens.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<resources>
+ <dimen name="mozac_feature_customtabs_max_close_button_size">24dp</dimen>
+</resources>
diff --git a/mobile/android/android-components/components/feature/customtabs/src/main/res/values/strings.xml b/mobile/android/android-components/components/feature/customtabs/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..f10d13b6ae
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/main/res/values/strings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ This Source Code Form is subject to the terms of the Mozilla Public
+ ~ License, v. 2.0. If a copy of the MPL was not distributed with this
+ ~ file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ -->
+
+<resources>
+ <string name="mozac_feature_customtabs_exit_button">Return to previous app</string>
+ <string name="mozac_feature_customtabs_share_link">Share link</string>
+</resources> \ No newline at end of file
diff --git a/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/AbstractCustomTabsServiceTest.kt b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/AbstractCustomTabsServiceTest.kt
new file mode 100644
index 0000000000..3b143f8721
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/AbstractCustomTabsServiceTest.kt
@@ -0,0 +1,130 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.customtabs
+
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Bundle
+import android.os.IBinder
+import android.support.customtabs.ICustomTabsCallback
+import android.support.customtabs.ICustomTabsService
+import androidx.browser.customtabs.CustomTabsService
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.concept.engine.Engine
+import mozilla.components.feature.customtabs.store.CustomTabsServiceStore
+import mozilla.components.service.digitalassetlinks.RelationChecker
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.rule.MainCoroutineRule
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class AbstractCustomTabsServiceTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ @Test
+ fun customTabService() {
+ val customTabsService = object : MockCustomTabsService() {
+ override val customTabsServiceStore = CustomTabsServiceStore()
+ override fun getPackageManager(): PackageManager = mock()
+ }
+
+ val customTabsServiceStub = customTabsService.onBind(mock())
+ assertNotNull(customTabsServiceStub)
+
+ val stub = customTabsServiceStub as ICustomTabsService.Stub
+
+ val callback = mock<ICustomTabsCallback>()
+ doReturn(mock<IBinder>()).`when`(callback).asBinder()
+
+ assertTrue(stub.warmup(123))
+ assertTrue(stub.newSession(callback))
+ assertNull(stub.extraCommand("", mock()))
+ assertFalse(stub.updateVisuals(mock(), mock()))
+ assertFalse(stub.requestPostMessageChannel(mock(), mock()))
+ assertEquals(
+ CustomTabsService.RESULT_FAILURE_DISALLOWED,
+ stub.postMessage(mock(), "", mock()),
+ )
+ assertFalse(
+ stub.validateRelationship(
+ mock(),
+ 0,
+ mock(),
+ mock(),
+ ),
+ )
+ assertTrue(
+ stub.mayLaunchUrl(
+ mock(),
+ mock(),
+ mock(),
+ emptyList<Bundle>(),
+ ),
+ )
+ }
+
+ @Test
+ fun `Warmup will access engine instance`() {
+ var engineAccessed = false
+
+ val customTabsService = object : MockCustomTabsService() {
+ override val engine: Engine
+ get() {
+ engineAccessed = true
+ return mock()
+ }
+ }
+
+ val stub = customTabsService.onBind(mock()) as ICustomTabsService.Stub
+
+ assertTrue(stub.warmup(42))
+
+ assertTrue(engineAccessed)
+ }
+
+ @Test
+ fun `mayLaunchUrl opens a speculative connection for most likely URL`() {
+ val engine: Engine = mock()
+
+ val customTabsService = object : MockCustomTabsService() {
+ override val engine: Engine = engine
+ }
+
+ val stub = customTabsService.onBind(mock()) as ICustomTabsService.Stub
+
+ assertTrue(stub.mayLaunchUrl(mock(), Uri.parse("https://www.mozilla.org"), Bundle(), listOf()))
+
+ verify(engine).speculativeConnect("https://www.mozilla.org")
+ }
+
+ @Test
+ fun `verifier is only created when store and client are provided`() {
+ val basic = MockCustomTabsService()
+ assertNull(basic.verifier)
+
+ val both = object : MockCustomTabsService() {
+ override val relationChecker: RelationChecker = mock()
+
+ override fun getPackageManager(): PackageManager = mock()
+ }
+ assertNotNull(both.verifier)
+ }
+
+ private open class MockCustomTabsService : AbstractCustomTabsService() {
+ override val engine: Engine = mock()
+ override val customTabsServiceStore: CustomTabsServiceStore = mock()
+ }
+}
diff --git a/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/CustomTabConfigHelperTest.kt b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/CustomTabConfigHelperTest.kt
new file mode 100644
index 0000000000..097d95796a
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/CustomTabConfigHelperTest.kt
@@ -0,0 +1,416 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.customtabs
+
+import android.app.PendingIntent
+import android.content.Intent
+import android.content.res.Resources
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.os.Binder
+import android.os.Build
+import android.os.Bundle
+import android.util.SparseArray
+import androidx.browser.customtabs.CustomTabColorSchemeParams
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.browser.customtabs.TrustedWebUtils
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.state.state.ColorSchemeParams
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.utils.toSafeIntent
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.`when`
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+class CustomTabConfigHelperTest {
+
+ private lateinit var resources: Resources
+
+ @Before
+ fun setup() {
+ resources = spy(testContext.resources)
+ doReturn(24f).`when`(resources).getDimension(R.dimen.mozac_feature_customtabs_max_close_button_size)
+ }
+
+ @Test
+ fun isCustomTabIntent() {
+ val customTabsIntent = CustomTabsIntent.Builder().build()
+ assertTrue(isCustomTabIntent(customTabsIntent.intent))
+ assertFalse(isCustomTabIntent(mock<Intent>()))
+ }
+
+ @Test
+ fun isTrustedWebActivityIntent() {
+ val customTabsIntent = CustomTabsIntent.Builder().build().intent
+ val trustedWebActivityIntent = Intent(customTabsIntent)
+ .putExtra(TrustedWebUtils.EXTRA_LAUNCH_AS_TRUSTED_WEB_ACTIVITY, true)
+ assertTrue(isTrustedWebActivityIntent(trustedWebActivityIntent))
+ assertFalse(isTrustedWebActivityIntent(customTabsIntent))
+ assertFalse(isTrustedWebActivityIntent(mock<Intent>()))
+ assertFalse(
+ isTrustedWebActivityIntent(
+ Intent().putExtra(TrustedWebUtils.EXTRA_LAUNCH_AS_TRUSTED_WEB_ACTIVITY, true),
+ ),
+ )
+ }
+
+ @Test
+ fun createFromIntentNoColorScheme() {
+ val customTabsIntent = CustomTabsIntent.Builder().build()
+
+ val result = createCustomTabConfigFromIntent(customTabsIntent.intent, testContext.resources)
+
+ assertEquals(null, result.colorScheme)
+ }
+
+ @Test
+ fun createFromIntentWithColorScheme() {
+ val colorScheme = CustomTabsIntent.COLOR_SCHEME_SYSTEM
+ val customTabsIntent = CustomTabsIntent.Builder().setColorScheme(colorScheme).build()
+
+ val result = createCustomTabConfigFromIntent(customTabsIntent.intent, testContext.resources)
+
+ assertEquals(colorScheme, result.colorScheme)
+ }
+
+ @Test
+ fun createFromIntentNoColorSchemeParams() {
+ val customTabsIntent = CustomTabsIntent.Builder().build()
+ val result = createCustomTabConfigFromIntent(customTabsIntent.intent, testContext.resources)
+
+ assertEquals(null, result.colorSchemes)
+ }
+
+ @Test
+ fun createFromIntentWithDefaultColorSchemeParams() {
+ val colorSchemeParams = createColorSchemeParams()
+ val customTabsIntent = CustomTabsIntent.Builder().setDefaultColorSchemeParams(
+ createCustomTabColorSchemeParamsFrom(colorSchemeParams),
+ ).build()
+
+ val result = createCustomTabConfigFromIntent(customTabsIntent.intent, testContext.resources)
+
+ assertEquals(colorSchemeParams, result.colorSchemes!!.defaultColorSchemeParams)
+ }
+
+ @Test
+ fun createFromIntentWithDefaultColorSchemeParamsWithNoProperties() {
+ val customTabsIntent = CustomTabsIntent.Builder().setDefaultColorSchemeParams(
+ CustomTabColorSchemeParams.Builder().build(),
+ ).build()
+
+ val result = createCustomTabConfigFromIntent(customTabsIntent.intent, testContext.resources)
+
+ assertEquals(null, result.colorSchemes?.defaultColorSchemeParams)
+ }
+
+ @Test
+ fun createFromIntentWithLightColorSchemeParams() {
+ val colorSchemeParams = createColorSchemeParams()
+ val customTabsIntent = CustomTabsIntent.Builder().setColorSchemeParams(
+ CustomTabsIntent.COLOR_SCHEME_LIGHT,
+ createCustomTabColorSchemeParamsFrom(colorSchemeParams),
+ ).build()
+
+ val result = createCustomTabConfigFromIntent(customTabsIntent.intent, testContext.resources)
+
+ assertEquals(colorSchemeParams, result.colorSchemes!!.lightColorSchemeParams)
+ }
+
+ @Test
+ fun createFromIntentWithLightColorSchemeParamsWithNoProperties() {
+ val customTabsIntent = CustomTabsIntent.Builder().setColorSchemeParams(
+ CustomTabsIntent.COLOR_SCHEME_LIGHT,
+ CustomTabColorSchemeParams.Builder().build(),
+ ).build()
+
+ val result = createCustomTabConfigFromIntent(customTabsIntent.intent, testContext.resources)
+
+ assertEquals(null, result.colorSchemes?.lightColorSchemeParams)
+ }
+
+ @Test
+ fun createFromIntentWithDarkColorSchemeParams() {
+ val colorSchemeParams = createColorSchemeParams()
+ val customTabsIntent = CustomTabsIntent.Builder().setColorSchemeParams(
+ CustomTabsIntent.COLOR_SCHEME_DARK,
+ createCustomTabColorSchemeParamsFrom(colorSchemeParams),
+ ).build()
+
+ val result = createCustomTabConfigFromIntent(customTabsIntent.intent, testContext.resources)
+
+ assertEquals(colorSchemeParams, result.colorSchemes!!.darkColorSchemeParams)
+ }
+
+ @Test
+ fun createFromIntentWithDarkColorSchemeParamsWithNoProperties() {
+ val customTabsIntent = CustomTabsIntent.Builder().setColorSchemeParams(
+ CustomTabsIntent.COLOR_SCHEME_DARK,
+ CustomTabColorSchemeParams.Builder().build(),
+ ).build()
+
+ val result = createCustomTabConfigFromIntent(customTabsIntent.intent, testContext.resources)
+
+ assertEquals(null, result.colorSchemes?.lightColorSchemeParams)
+ }
+
+ @Test
+ @Config(sdk = [Build.VERSION_CODES.TIRAMISU])
+ fun getColorSchemeParamsBundleOnAndroidVersionTiramisu() {
+ val colorScheme = CustomTabsIntent.COLOR_SCHEME_DARK
+ val colorSchemeParams = createColorSchemeParams()
+ val customTabColorScheme = createCustomTabColorSchemeParamsFrom(colorSchemeParams)
+ val customTabsIntent = CustomTabsIntent.Builder().setColorSchemeParams(
+ colorScheme,
+ customTabColorScheme,
+ ).build()
+
+ val result = customTabsIntent.intent.toSafeIntent().getColorSchemeParamsBundle()!!
+ val expected = SparseArray<Bundle>()
+ expected.put(colorScheme, createBundleFrom(customTabColorScheme))
+
+ result[colorScheme].assertEquals(expected[colorScheme])
+ }
+
+ @Test
+ @Config(sdk = [Build.VERSION_CODES.S_V2])
+ fun getColorSchemeParamsBundlePreAndroidVersionTiramisu() {
+ val colorScheme = CustomTabsIntent.COLOR_SCHEME_DARK
+ val colorSchemeParams = createColorSchemeParams()
+ val customTabColorScheme = createCustomTabColorSchemeParamsFrom(colorSchemeParams)
+ val customTabsIntent = CustomTabsIntent.Builder().setColorSchemeParams(
+ colorScheme,
+ customTabColorScheme,
+ ).build()
+
+ val result = customTabsIntent.intent.toSafeIntent().getColorSchemeParamsBundle()!!
+ val expected = SparseArray<Bundle>()
+ expected.put(colorScheme, createBundleFrom(customTabColorScheme))
+
+ result[colorScheme].assertEquals(expected[colorScheme])
+ }
+
+ @Test
+ fun createFromIntentWithCloseButton() {
+ val size = 24
+ val builder = CustomTabsIntent.Builder()
+ val closeButtonIcon = Bitmap.createBitmap(IntArray(size * size), size, size, Bitmap.Config.ARGB_8888)
+ builder.setCloseButtonIcon(closeButtonIcon)
+
+ val customTabConfig = createCustomTabConfigFromIntent(builder.build().intent, testContext.resources)
+ assertEquals(closeButtonIcon, customTabConfig.closeButtonIcon)
+ assertEquals(size, customTabConfig.closeButtonIcon?.width)
+ assertEquals(size, customTabConfig.closeButtonIcon?.height)
+
+ val customTabConfigNoResources = createCustomTabConfigFromIntent(builder.build().intent, null)
+ assertEquals(closeButtonIcon, customTabConfigNoResources.closeButtonIcon)
+ assertEquals(size, customTabConfigNoResources.closeButtonIcon?.width)
+ assertEquals(size, customTabConfigNoResources.closeButtonIcon?.height)
+ }
+
+ @Test
+ fun createFromIntentWithMaxOversizedCloseButton() {
+ val size = 64
+ val builder = CustomTabsIntent.Builder()
+ val closeButtonIcon = Bitmap.createBitmap(IntArray(size * size), size, size, Bitmap.Config.ARGB_8888)
+ builder.setCloseButtonIcon(closeButtonIcon)
+
+ val customTabConfig = createCustomTabConfigFromIntent(builder.build().intent, testContext.resources)
+ assertNull(customTabConfig.closeButtonIcon)
+
+ val customTabConfigNoResources = createCustomTabConfigFromIntent(builder.build().intent, null)
+ assertEquals(closeButtonIcon, customTabConfigNoResources.closeButtonIcon)
+ }
+
+ @Test
+ fun createFromIntentUsingDisplayMetricsForCloseButton() {
+ val size = 64
+ val builder = CustomTabsIntent.Builder()
+ val resources: Resources = mock()
+ val closeButtonIcon = Bitmap.createBitmap(IntArray(size * size), size, size, Bitmap.Config.ARGB_8888)
+ builder.setCloseButtonIcon(closeButtonIcon)
+
+ `when`(resources.getDimension(R.dimen.mozac_feature_customtabs_max_close_button_size)).thenReturn(64f)
+
+ val customTabConfig = createCustomTabConfigFromIntent(builder.build().intent, resources)
+ assertEquals(closeButtonIcon, customTabConfig.closeButtonIcon)
+ }
+
+ @Test
+ fun createFromIntentWithInvalidCloseButton() {
+ val customTabsIntent = CustomTabsIntent.Builder().build()
+ // Intent is a parcelable but not a Bitmap
+ customTabsIntent.intent.putExtra(CustomTabsIntent.EXTRA_CLOSE_BUTTON_ICON, Intent())
+
+ val customTabConfig = createCustomTabConfigFromIntent(customTabsIntent.intent, testContext.resources)
+ assertNull(customTabConfig.closeButtonIcon)
+ }
+
+ @Test
+ fun createFromIntentWithUrlbarHiding() {
+ val builder = CustomTabsIntent.Builder()
+ builder.setUrlBarHidingEnabled(true)
+
+ val customTabConfig = createCustomTabConfigFromIntent(builder.build().intent, testContext.resources)
+ assertTrue(customTabConfig.enableUrlbarHiding)
+ }
+
+ @Test
+ fun createFromIntentWithShareMenuItem() {
+ val builder = CustomTabsIntent.Builder()
+ builder.setShareState(CustomTabsIntent.SHARE_STATE_ON)
+
+ val customTabConfig = createCustomTabConfigFromIntent(builder.build().intent, testContext.resources)
+ assertTrue(customTabConfig.showShareMenuItem)
+ }
+
+ @Test
+ fun createFromIntentWithShareState() {
+ val builder = CustomTabsIntent.Builder()
+ builder.setShareState(CustomTabsIntent.SHARE_STATE_ON)
+
+ val extraShareState = builder.build().intent.getIntExtra(CustomTabsIntent.EXTRA_SHARE_STATE, 5)
+ assertEquals(CustomTabsIntent.SHARE_STATE_ON, extraShareState)
+ }
+
+ @Test
+ fun createFromIntentWithCustomizedMenu() {
+ val builder = CustomTabsIntent.Builder()
+ val pendingIntent = PendingIntent.getActivity(null, 0, null, 0)
+ builder.addMenuItem("menuitem1", pendingIntent)
+ builder.addMenuItem("menuitem2", pendingIntent)
+
+ val customTabConfig = createCustomTabConfigFromIntent(builder.build().intent, testContext.resources)
+ assertEquals(2, customTabConfig.menuItems.size)
+ assertEquals("menuitem1", customTabConfig.menuItems[0].name)
+ assertSame(pendingIntent, customTabConfig.menuItems[0].pendingIntent)
+ assertEquals("menuitem2", customTabConfig.menuItems[1].name)
+ assertSame(pendingIntent, customTabConfig.menuItems[1].pendingIntent)
+ }
+
+ @Test
+ fun createFromIntentWithActionButton() {
+ val builder = CustomTabsIntent.Builder()
+
+ val bitmap = mock<Bitmap>()
+ val intent = PendingIntent.getActivity(testContext, 0, Intent("testAction"), 0)
+ builder.setActionButton(bitmap, "desc", intent)
+
+ val customTabsIntent = builder.build()
+ val customTabConfig = createCustomTabConfigFromIntent(customTabsIntent.intent, testContext.resources)
+
+ assertNotNull(customTabConfig.actionButtonConfig)
+ assertEquals("desc", customTabConfig.actionButtonConfig?.description)
+ assertEquals(intent, customTabConfig.actionButtonConfig?.pendingIntent)
+ assertEquals(bitmap, customTabConfig.actionButtonConfig?.icon)
+ assertFalse(customTabConfig.actionButtonConfig!!.tint)
+ }
+
+ @Test
+ fun createFromIntentWithInvalidActionButton() {
+ val customTabsIntent = CustomTabsIntent.Builder().build()
+
+ val invalid = Bundle()
+ customTabsIntent.intent.putExtra(CustomTabsIntent.EXTRA_ACTION_BUTTON_BUNDLE, invalid)
+ val customTabConfig = createCustomTabConfigFromIntent(customTabsIntent.intent, testContext.resources)
+
+ assertNull(customTabConfig.actionButtonConfig)
+ }
+
+ @Test
+ fun createFromIntentWithInvalidExtras() {
+ val customTabsIntent = CustomTabsIntent.Builder().build()
+
+ val extrasField = Intent::class.java.getDeclaredField("mExtras")
+ extrasField.isAccessible = true
+ extrasField.set(customTabsIntent.intent, null)
+ extrasField.isAccessible = false
+
+ assertFalse(isCustomTabIntent(customTabsIntent.intent))
+
+ // Make sure we're not failing
+ val customTabConfig = createCustomTabConfigFromIntent(customTabsIntent.intent, testContext.resources)
+ assertNotNull(customTabConfig)
+ assertNull(customTabConfig.actionButtonConfig)
+ }
+
+ @Test
+ fun createFromIntentWithExitAnimationOption() {
+ val customTabsIntent = CustomTabsIntent.Builder().build()
+ val bundle = Bundle()
+ customTabsIntent.intent.putExtra(CustomTabsIntent.EXTRA_EXIT_ANIMATION_BUNDLE, bundle)
+
+ val customTabConfig = createCustomTabConfigFromIntent(customTabsIntent.intent, testContext.resources)
+ assertEquals(bundle, customTabConfig.exitAnimations)
+ }
+
+ @Test
+ fun createFromIntentWithPageTitleOption() {
+ val customTabsIntent = CustomTabsIntent.Builder().build()
+ customTabsIntent.intent.putExtra(CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE, CustomTabsIntent.SHOW_PAGE_TITLE)
+
+ val customTabConfig = createCustomTabConfigFromIntent(customTabsIntent.intent, testContext.resources)
+ assertTrue(customTabConfig.titleVisible)
+ }
+
+ @Test
+ fun createFromIntentWithSessionToken() {
+ val customTabsIntent: Intent = mock()
+ val bundle: Bundle = mock()
+ val binder: Binder = mock()
+ `when`(customTabsIntent.extras).thenReturn(bundle)
+ `when`(bundle.getBinder(CustomTabsIntent.EXTRA_SESSION)).thenReturn(binder)
+
+ val customTabConfig = createCustomTabConfigFromIntent(customTabsIntent, testContext.resources)
+ assertNotNull(customTabConfig.sessionToken)
+ }
+
+ private fun createColorSchemeParams() = ColorSchemeParams(
+ toolbarColor = Color.BLACK,
+ secondaryToolbarColor = Color.RED,
+ navigationBarColor = Color.BLUE,
+ navigationBarDividerColor = Color.YELLOW,
+ )
+
+ private fun createCustomTabColorSchemeParamsFrom(colorSchemeParams: ColorSchemeParams): CustomTabColorSchemeParams {
+ val customTabColorSchemeBuilder = CustomTabColorSchemeParams.Builder()
+ customTabColorSchemeBuilder.setToolbarColor(colorSchemeParams.toolbarColor!!)
+ customTabColorSchemeBuilder.setSecondaryToolbarColor(colorSchemeParams.secondaryToolbarColor!!)
+ customTabColorSchemeBuilder.setNavigationBarColor(colorSchemeParams.navigationBarColor!!)
+ customTabColorSchemeBuilder.setNavigationBarDividerColor(colorSchemeParams.navigationBarDividerColor!!)
+ return customTabColorSchemeBuilder.build()
+ }
+
+ private fun createBundleFrom(customTabColorScheme: CustomTabColorSchemeParams): Bundle {
+ val expectedBundle = Bundle()
+ expectedBundle.putInt(CustomTabsIntent.EXTRA_TOOLBAR_COLOR, customTabColorScheme.toolbarColor!!)
+ expectedBundle.putInt(CustomTabsIntent.EXTRA_SECONDARY_TOOLBAR_COLOR, customTabColorScheme.secondaryToolbarColor!!)
+ expectedBundle.putInt(CustomTabsIntent.EXTRA_NAVIGATION_BAR_COLOR, customTabColorScheme.navigationBarColor!!)
+ expectedBundle.putInt(CustomTabsIntent.EXTRA_NAVIGATION_BAR_DIVIDER_COLOR, customTabColorScheme.navigationBarDividerColor!!)
+ return expectedBundle
+ }
+
+ /**
+ * As Bundle does not implement Equals, assert the values individually.
+ */
+ private fun Bundle.assertEquals(bundle: Bundle) {
+ assertEquals(bundle.getInt(CustomTabsIntent.EXTRA_TOOLBAR_COLOR), getInt(CustomTabsIntent.EXTRA_TOOLBAR_COLOR))
+ assertEquals(bundle.getInt(CustomTabsIntent.EXTRA_SECONDARY_TOOLBAR_COLOR), getInt(CustomTabsIntent.EXTRA_SECONDARY_TOOLBAR_COLOR))
+ assertEquals(bundle.getInt(CustomTabsIntent.EXTRA_NAVIGATION_BAR_COLOR), getInt(CustomTabsIntent.EXTRA_NAVIGATION_BAR_COLOR))
+ assertEquals(bundle.getInt(CustomTabsIntent.EXTRA_NAVIGATION_BAR_DIVIDER_COLOR), getInt(CustomTabsIntent.EXTRA_NAVIGATION_BAR_DIVIDER_COLOR))
+ }
+}
diff --git a/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/CustomTabIntentProcessorTest.kt b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/CustomTabIntentProcessorTest.kt
new file mode 100644
index 0000000000..521726f404
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/CustomTabIntentProcessorTest.kt
@@ -0,0 +1,175 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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.customtabs
+
+import android.content.Intent
+import android.os.Bundle
+import android.provider.Browser
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import mozilla.components.browser.state.action.BrowserAction
+import mozilla.components.browser.state.action.CustomTabListAction
+import mozilla.components.browser.state.action.EngineAction
+import mozilla.components.browser.state.selector.findCustomTab
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.SessionState.Source
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineSession.LoadUrlFlags
+import mozilla.components.feature.intent.ext.EXTRA_SESSION_ID
+import mozilla.components.feature.session.SessionUseCases
+import mozilla.components.feature.tabs.CustomTabsUseCases
+import mozilla.components.support.test.any
+import mozilla.components.support.test.libstate.ext.waitUntilIdle
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import mozilla.components.support.test.whenever
+import mozilla.components.support.utils.toSafeIntent
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+@ExperimentalCoroutinesApi
+class CustomTabIntentProcessorTest {
+ @Test
+ fun processCustomTabIntentWithDefaultHandlers() {
+ val middleware = CaptureActionsMiddleware<BrowserState, BrowserAction>()
+ val store = BrowserStore(middleware = listOf(middleware))
+ val useCases = SessionUseCases(store)
+ val customTabsUseCases = CustomTabsUseCases(store, useCases.loadUrl)
+
+ val handler =
+ CustomTabIntentProcessor(customTabsUseCases.add, testContext.resources)
+
+ val intent = mock<Intent>()
+ whenever(intent.action).thenReturn(Intent.ACTION_VIEW)
+ whenever(intent.hasExtra(CustomTabsIntent.EXTRA_SESSION)).thenReturn(true)
+ whenever(intent.dataString).thenReturn("http://mozilla.org")
+ whenever(intent.putExtra(any<String>(), any<String>())).thenReturn(intent)
+
+ handler.process(intent)
+
+ store.waitUntilIdle()
+
+ var customTabId: String? = null
+
+ middleware.assertFirstAction(CustomTabListAction.AddCustomTabAction::class) { action ->
+ customTabId = action.tab.id
+ }
+
+ middleware.assertFirstAction(EngineAction.LoadUrlAction::class) { action ->
+ assertEquals(customTabId, action.tabId)
+ assertEquals("http://mozilla.org", action.url)
+ assertEquals(LoadUrlFlags.external(), action.flags)
+ }
+
+ verify(intent).putExtra(eq(EXTRA_SESSION_ID), any<String>())
+
+ val customTab = store.state.findCustomTab(customTabId!!)
+ assertNotNull(customTab!!)
+ assertEquals("http://mozilla.org", customTab.content.url)
+ assertTrue(customTab.source is Source.External.CustomTab)
+ assertNotNull(customTab.config)
+ assertFalse(customTab.content.private)
+ }
+
+ @Test
+ fun processCustomTabIntentWithAdditionalHeaders() {
+ val middleware = CaptureActionsMiddleware<BrowserState, BrowserAction>()
+ val store = BrowserStore(middleware = listOf(middleware))
+ val useCases = SessionUseCases(store)
+ val customTabsUseCases = CustomTabsUseCases(store, useCases.loadUrl)
+
+ val handler =
+ CustomTabIntentProcessor(customTabsUseCases.add, testContext.resources)
+
+ val intent = mock<Intent>()
+ whenever(intent.action).thenReturn(Intent.ACTION_VIEW)
+ whenever(intent.hasExtra(CustomTabsIntent.EXTRA_SESSION)).thenReturn(true)
+ whenever(intent.dataString).thenReturn("http://mozilla.org")
+ whenever(intent.putExtra(any<String>(), any<String>())).thenReturn(intent)
+
+ val headersBundle = Bundle().apply {
+ putString("X-Extra-Header", "true")
+ }
+ whenever(intent.getBundleExtra(Browser.EXTRA_HEADERS)).thenReturn(headersBundle)
+ val headers = handler.getAdditionalHeaders(intent.toSafeIntent())
+
+ handler.process(intent)
+
+ store.waitUntilIdle()
+
+ var customTabId: String? = null
+
+ middleware.assertFirstAction(CustomTabListAction.AddCustomTabAction::class) { action ->
+ customTabId = action.tab.id
+ }
+
+ middleware.assertFirstAction(EngineAction.LoadUrlAction::class) { action ->
+ assertEquals(customTabId, action.tabId)
+ assertEquals("http://mozilla.org", action.url)
+ assertEquals(LoadUrlFlags.external(), action.flags)
+ assertEquals(headers, action.additionalHeaders)
+ }
+
+ verify(intent).putExtra(eq(EXTRA_SESSION_ID), any<String>())
+
+ val customTab = store.state.findCustomTab(customTabId!!)
+ assertNotNull(customTab!!)
+ assertEquals("http://mozilla.org", customTab.content.url)
+ assertTrue(customTab.source is Source.External.CustomTab)
+ assertNotNull(customTab.config)
+ assertFalse(customTab.content.private)
+ }
+
+ @Test
+ fun processPrivateCustomTabIntentWithDefaultHandlers() {
+ val middleware = CaptureActionsMiddleware<BrowserState, BrowserAction>()
+ val store = BrowserStore(middleware = listOf(middleware))
+ val useCases = SessionUseCases(store)
+ val customTabsUseCases = CustomTabsUseCases(store, useCases.loadUrl)
+
+ val handler =
+ CustomTabIntentProcessor(customTabsUseCases.add, testContext.resources, true)
+
+ val intent = mock<Intent>()
+ whenever(intent.action).thenReturn(Intent.ACTION_VIEW)
+ whenever(intent.hasExtra(CustomTabsIntent.EXTRA_SESSION)).thenReturn(true)
+ whenever(intent.dataString).thenReturn("http://mozilla.org")
+ whenever(intent.putExtra(any<String>(), any<String>())).thenReturn(intent)
+
+ handler.process(intent)
+
+ store.waitUntilIdle()
+
+ var customTabId: String? = null
+
+ middleware.assertFirstAction(CustomTabListAction.AddCustomTabAction::class) { action ->
+ customTabId = action.tab.id
+ }
+
+ middleware.assertFirstAction(EngineAction.LoadUrlAction::class) { action ->
+ assertEquals(customTabId, action.tabId)
+ assertEquals("http://mozilla.org", action.url)
+ assertEquals(LoadUrlFlags.external(), action.flags)
+ }
+
+ verify(intent).putExtra(eq(EXTRA_SESSION_ID), any<String>())
+
+ val customTab = store.state.findCustomTab(customTabId!!)
+ assertNotNull(customTab!!)
+ assertEquals("http://mozilla.org", customTab.content.url)
+ assertTrue(customTab.source is Source.External.CustomTab)
+ assertNotNull(customTab.config)
+ assertTrue(customTab.content.private)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/CustomTabWindowFeatureTest.kt b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/CustomTabWindowFeatureTest.kt
new file mode 100644
index 0000000000..8be4a6edab
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/CustomTabWindowFeatureTest.kt
@@ -0,0 +1,164 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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.customtabs
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.graphics.Color
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.ColorSchemeParams
+import mozilla.components.browser.state.state.ColorSchemes
+import mozilla.components.browser.state.state.CustomTabActionButtonConfig
+import mozilla.components.browser.state.state.CustomTabConfig
+import mozilla.components.browser.state.state.CustomTabMenuItem
+import mozilla.components.browser.state.state.createCustomTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.concept.engine.EngineSession
+import mozilla.components.concept.engine.window.WindowRequest
+import mozilla.components.support.test.any
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.rule.MainCoroutineRule
+import mozilla.components.support.test.whenever
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class CustomTabWindowFeatureTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private lateinit var store: BrowserStore
+ private val sessionId = "session-uuid"
+ private lateinit var activity: Activity
+ private lateinit var engineSession: EngineSession
+
+ @Before
+ fun setup() {
+ activity = mock()
+ engineSession = mock()
+
+ store = spy(
+ BrowserStore(
+ BrowserState(
+ customTabs = listOf(
+ createCustomTab(
+ id = sessionId,
+ url = "https://www.mozilla.org",
+ engineSession = engineSession,
+ ),
+ ),
+ ),
+ ),
+ )
+
+ whenever(activity.packageName).thenReturn("org.mozilla.firefox")
+ }
+
+ @Test
+ fun `given a request to open window, when the url can be handled, then the activity should start`() {
+ val feature = spy(CustomTabWindowFeature(activity, store, sessionId))
+ val windowRequest: WindowRequest = mock()
+
+ feature.start()
+ whenever(windowRequest.type).thenReturn(WindowRequest.Type.OPEN)
+ whenever(windowRequest.url).thenReturn("https://www.firefox.com")
+ store.dispatch(ContentAction.UpdateWindowRequestAction(sessionId, windowRequest)).joinBlocking()
+
+ verify(activity).startActivity(any(), any())
+ verify(store).dispatch(ContentAction.ConsumeWindowRequestAction(sessionId))
+ }
+
+ @Test
+ fun `given a request to open window, when the url can't be handled, then handleError should be called`() {
+ val exception = ActivityNotFoundException()
+ val feature = spy(CustomTabWindowFeature(activity, store, sessionId))
+ val windowRequest: WindowRequest = mock()
+
+ feature.start()
+ whenever(windowRequest.type).thenReturn(WindowRequest.Type.OPEN)
+ whenever(windowRequest.url).thenReturn("blob:https://www.firefox.com")
+ whenever(activity.startActivity(any(), any())).thenThrow(exception)
+ store.dispatch(ContentAction.UpdateWindowRequestAction(sessionId, windowRequest)).joinBlocking()
+ verify(engineSession).loadUrl("blob:https://www.firefox.com")
+ }
+
+ @Test
+ fun `creates intent based on default custom tab config`() {
+ val feature = CustomTabWindowFeature(activity, store, sessionId)
+ val config = CustomTabConfig()
+ val intent = feature.configToIntent(config)
+
+ val newConfig = createCustomTabConfigFromIntent(intent.intent, null)
+ assertEquals("org.mozilla.firefox", intent.intent.`package`)
+ assertEquals(config, newConfig)
+ }
+
+ @Test
+ fun `creates intent based on custom tab config`() {
+ val feature = CustomTabWindowFeature(activity, store, sessionId)
+ val config = CustomTabConfig(
+ colorSchemes = ColorSchemes(
+ defaultColorSchemeParams = ColorSchemeParams(
+ toolbarColor = Color.RED,
+ navigationBarColor = Color.BLUE,
+ ),
+ ),
+ enableUrlbarHiding = true,
+ showShareMenuItem = true,
+ titleVisible = true,
+ )
+ val intent = feature.configToIntent(config)
+
+ val newConfig = createCustomTabConfigFromIntent(intent.intent, null)
+ assertEquals("org.mozilla.firefox", intent.intent.`package`)
+ assertEquals(config, newConfig)
+ }
+
+ @Test
+ fun `creates intent with same menu items`() {
+ val feature = CustomTabWindowFeature(activity, store, sessionId)
+ val config = CustomTabConfig(
+ actionButtonConfig = CustomTabActionButtonConfig(
+ description = "button",
+ icon = mock(),
+ pendingIntent = mock(),
+ ),
+ menuItems = listOf(
+ CustomTabMenuItem("Item A", mock()),
+ CustomTabMenuItem("Item B", mock()),
+ CustomTabMenuItem("Item C", mock()),
+ ),
+ )
+ val intent = feature.configToIntent(config)
+
+ val newConfig = createCustomTabConfigFromIntent(intent.intent, null)
+ assertEquals("org.mozilla.firefox", intent.intent.`package`)
+ assertEquals(config, newConfig)
+ }
+
+ @Test
+ fun `handles no requests when stopped`() {
+ val feature = CustomTabWindowFeature(activity, store, sessionId)
+ feature.start()
+ feature.stop()
+
+ val windowRequest: WindowRequest = mock()
+ whenever(windowRequest.type).thenReturn(WindowRequest.Type.OPEN)
+ whenever(windowRequest.url).thenReturn("https://www.firefox.com")
+ store.dispatch(ContentAction.UpdateWindowRequestAction(sessionId, windowRequest)).joinBlocking()
+ verify(activity, never()).startActivity(any(), any())
+ verify(store, never()).dispatch(ContentAction.ConsumeWindowRequestAction(sessionId))
+ }
+}
diff --git a/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/CustomTabsToolbarFeatureTest.kt b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/CustomTabsToolbarFeatureTest.kt
new file mode 100644
index 0000000000..4a97ba985a
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/CustomTabsToolbarFeatureTest.kt
@@ -0,0 +1,1582 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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.customtabs
+
+import android.app.PendingIntent
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.view.ViewGroup
+import android.view.Window
+import android.widget.FrameLayout
+import android.widget.ImageButton
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.core.content.ContextCompat.getColor
+import androidx.core.view.forEach
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import mozilla.components.browser.menu.BrowserMenu
+import mozilla.components.browser.menu.BrowserMenuBuilder
+import mozilla.components.browser.menu.item.SimpleBrowserMenuItem
+import mozilla.components.browser.state.action.BrowserAction
+import mozilla.components.browser.state.action.ContentAction
+import mozilla.components.browser.state.action.CustomTabListAction
+import mozilla.components.browser.state.state.BrowserState
+import mozilla.components.browser.state.state.ColorSchemeParams
+import mozilla.components.browser.state.state.ColorSchemes
+import mozilla.components.browser.state.state.CustomTabActionButtonConfig
+import mozilla.components.browser.state.state.CustomTabConfig
+import mozilla.components.browser.state.state.CustomTabMenuItem
+import mozilla.components.browser.state.state.createCustomTab
+import mozilla.components.browser.state.store.BrowserStore
+import mozilla.components.browser.toolbar.BrowserToolbar
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.feature.session.SessionUseCases
+import mozilla.components.feature.tabs.CustomTabsUseCases
+import mozilla.components.support.ktx.android.content.res.resolveAttribute
+import mozilla.components.support.test.any
+import mozilla.components.support.test.argumentCaptor
+import mozilla.components.support.test.ext.joinBlocking
+import mozilla.components.support.test.middleware.CaptureActionsMiddleware
+import mozilla.components.support.test.mock
+import mozilla.components.support.test.robolectric.testContext
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyList
+import org.mockito.Mockito.anyInt
+import org.mockito.Mockito.doNothing
+import org.mockito.Mockito.never
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
+
+@RunWith(AndroidJUnit4::class)
+class CustomTabsToolbarFeatureTest {
+ @Test
+ fun `start without sessionId invokes nothing`() {
+ val store = BrowserStore()
+ val toolbar: BrowserToolbar = mock()
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(CustomTabsToolbarFeature(store, toolbar, sessionId = null, useCases = useCases) {})
+
+ feature.start()
+
+ verify(feature, never()).init(any())
+ }
+
+ @Test
+ fun `start calls initialize with the sessionId`() {
+ val tab = createCustomTab("https://www.mozilla.org", id = "mozilla")
+
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = BrowserToolbar(testContext)
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(CustomTabsToolbarFeature(store, toolbar, sessionId = "mozilla", useCases = useCases) {})
+
+ feature.start()
+
+ verify(feature).init(tab.config)
+
+ // Calling start again should NOT call init again
+
+ feature.start()
+
+ verify(feature, times(1)).init(tab.config)
+ }
+
+ @Test
+ fun `initialize updates toolbar`() {
+ val tab = createCustomTab("https://www.mozilla.org", id = "mozilla")
+
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = BrowserToolbar(testContext)
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = CustomTabsToolbarFeature(store, toolbar, sessionId = "mozilla", useCases = useCases) {}
+
+ feature.init(tab.config)
+
+ assertFalse(toolbar.display.onUrlClicked.invoke())
+ }
+
+ @Test
+ fun `initialize updates toolbar, window and text color`() {
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ colorSchemes = ColorSchemes(
+ defaultColorSchemeParams = ColorSchemeParams(
+ toolbarColor = Color.RED,
+ navigationBarColor = Color.BLUE,
+ ),
+ ),
+ ),
+ )
+
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val window: Window = mock()
+ `when`(window.decorView).thenReturn(mock())
+ val feature = CustomTabsToolbarFeature(store, toolbar, sessionId = "mozilla", useCases = useCases, window = window) {}
+
+ feature.init(tab.config)
+
+ verify(toolbar).setBackgroundColor(Color.RED)
+ verify(window).statusBarColor = Color.RED
+ verify(window).navigationBarColor = Color.BLUE
+
+ assertEquals(Color.WHITE, toolbar.display.colors.title)
+ assertEquals(Color.WHITE, toolbar.display.colors.text)
+ }
+
+ @Test
+ fun `initialize does not update toolbar background if flag is set`() {
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ colorSchemes = ColorSchemes(
+ defaultColorSchemeParams = ColorSchemeParams(toolbarColor = Color.RED),
+ ),
+ ),
+ )
+
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val window: Window = mock()
+ `when`(window.decorView).thenReturn(mock())
+
+ run {
+ val feature = CustomTabsToolbarFeature(
+ store,
+ toolbar,
+ sessionId = "mozilla",
+ useCases = useCases,
+ window = window,
+ updateTheme = false,
+ ) {}
+
+ feature.init(tab.config)
+
+ verify(toolbar, never()).setBackgroundColor(Color.RED)
+ }
+
+ run {
+ val feature = CustomTabsToolbarFeature(
+ store,
+ toolbar,
+ sessionId = "mozilla",
+ useCases = useCases,
+ window = window,
+ updateTheme = true,
+ ) {}
+
+ feature.init(tab.config)
+
+ verify(toolbar).setBackgroundColor(Color.RED)
+ }
+ }
+
+ @Test
+ fun `adds close button`() {
+ val tab = createCustomTab("https://www.mozilla.org", id = "mozilla", config = CustomTabConfig())
+
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = CustomTabsToolbarFeature(store, toolbar, sessionId = "mozilla", useCases = useCases) {}
+
+ feature.start()
+
+ verify(toolbar).addNavigationAction(any())
+ }
+
+ @Test
+ fun `doesn't add close button if the button should be hidden`() {
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ showCloseButton = false,
+ ),
+ )
+
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = CustomTabsToolbarFeature(store, toolbar, sessionId = "mozilla", useCases = useCases) {}
+
+ feature.start()
+
+ verify(toolbar, never()).addNavigationAction(any())
+ }
+
+ @Test
+ fun `close button invokes callback and removes session`() {
+ val middleware = CaptureActionsMiddleware<BrowserState, BrowserAction>()
+
+ val store = BrowserStore(
+ middleware = listOf(middleware),
+ initialState = BrowserState(
+ customTabs = listOf(
+ createCustomTab("https://www.mozilla.org", id = "mozilla", config = CustomTabConfig()),
+ ),
+ ),
+ )
+
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ var closeClicked = false
+ val feature = CustomTabsToolbarFeature(store, toolbar, sessionId = "mozilla", useCases = useCases) {
+ closeClicked = true
+ }
+
+ feature.start()
+
+ verify(toolbar).addNavigationAction(any())
+
+ val button = extractActionView(toolbar, testContext.getString(R.string.mozac_feature_customtabs_exit_button))
+
+ middleware.assertNotDispatched(CustomTabListAction.RemoveCustomTabAction::class)
+
+ button?.performClick()
+
+ assertTrue(closeClicked)
+
+ middleware.assertLastAction(CustomTabListAction.RemoveCustomTabAction::class) { action ->
+ assertEquals("mozilla", action.tabId)
+ }
+ }
+
+ @Test
+ fun `does not add share button by default`() {
+ val tab = createCustomTab("https://www.mozilla.org", id = "mozilla", config = CustomTabConfig())
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(CustomTabsToolbarFeature(store, toolbar, sessionId = "mozilla", useCases = useCases) {})
+
+ feature.start()
+
+ verify(feature, never()).addShareButton(anyInt())
+ verify(toolbar, never()).addBrowserAction(any())
+ }
+
+ @Test
+ fun `adds share button`() {
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ showShareMenuItem = true,
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(CustomTabsToolbarFeature(store, toolbar, sessionId = "mozilla", useCases = useCases) {})
+
+ feature.start()
+
+ verify(feature).addShareButton(anyInt())
+ verify(toolbar).addBrowserAction(any())
+ }
+
+ @Test
+ fun `share button uses custom share listener`() {
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ showShareMenuItem = true,
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ var clicked = false
+ val feature = CustomTabsToolbarFeature(
+ store,
+ toolbar,
+ sessionId = "mozilla",
+ useCases = useCases,
+ shareListener = { clicked = true },
+ ) {}
+
+ feature.start()
+
+ val captor = argumentCaptor<Toolbar.ActionButton>()
+ verify(toolbar).addBrowserAction(captor.capture())
+
+ val button = captor.value.createView(FrameLayout(testContext))
+ button.performClick()
+ assertTrue(clicked)
+ }
+
+ @Test
+ fun `initialize calls addActionButton`() {
+ val tab = createCustomTab("https://www.mozilla.org", id = "mozilla", config = CustomTabConfig())
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(CustomTabsToolbarFeature(store, toolbar, sessionId = "mozilla", useCases = useCases) {})
+
+ feature.start()
+
+ verify(feature).addActionButton(anyInt(), any())
+ }
+
+ @Test
+ fun `GIVEN a square icon larger than the max drawable size WHEN adding action button to toolbar THEN the icon is scaled to fit`() {
+ val captor = argumentCaptor<Toolbar.ActionButton>()
+ val size = 48
+ val pendingIntent: PendingIntent = mock()
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ actionButtonConfig = CustomTabActionButtonConfig(
+ description = "Button",
+ icon = Bitmap.createBitmap(IntArray(size * size), size, size, Bitmap.Config.ARGB_8888),
+ pendingIntent = pendingIntent,
+ ),
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(CustomTabsToolbarFeature(store, toolbar, sessionId = "mozilla", useCases = useCases) {})
+
+ feature.start()
+
+ verify(feature).addActionButton(anyInt(), any())
+ verify(toolbar).addBrowserAction(captor.capture())
+
+ val button = captor.value.createView(FrameLayout(testContext))
+ assertEquals(24, (button as ImageButton).drawable.intrinsicHeight)
+ assertEquals(24, button.drawable.intrinsicWidth)
+ }
+
+ @Test
+ fun `GIVEN a wide icon larger than the max drawable size WHEN adding action button to toolbar THEN the icon is scaled to fit`() {
+ val captor = argumentCaptor<Toolbar.ActionButton>()
+ val width = 96
+ val height = 48
+ val pendingIntent: PendingIntent = mock()
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ actionButtonConfig = CustomTabActionButtonConfig(
+ description = "Button",
+ icon = Bitmap.createBitmap(IntArray(width * height), width, height, Bitmap.Config.ARGB_8888),
+ pendingIntent = pendingIntent,
+ ),
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(CustomTabsToolbarFeature(store, toolbar, sessionId = "mozilla", useCases = useCases) {})
+
+ feature.start()
+
+ verify(feature).addActionButton(anyInt(), any())
+ verify(toolbar).addBrowserAction(captor.capture())
+
+ val button = captor.value.createView(FrameLayout(testContext))
+ assertEquals(24, (button as ImageButton).drawable.intrinsicHeight)
+ assertEquals(48, button.drawable.intrinsicWidth)
+ }
+
+ @Test
+ fun `GIVEN a tall icon larger than the max drawable size WHEN adding action button to toolbar THEN the icon is scaled to fit`() {
+ val captor = argumentCaptor<Toolbar.ActionButton>()
+ val width = 24
+ val height = 48
+ val pendingIntent: PendingIntent = mock()
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ actionButtonConfig = CustomTabActionButtonConfig(
+ description = "Button",
+ icon = Bitmap.createBitmap(IntArray(width * height), width, height, Bitmap.Config.ARGB_8888),
+ pendingIntent = pendingIntent,
+ ),
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(CustomTabsToolbarFeature(store, toolbar, sessionId = "mozilla", useCases = useCases) {})
+
+ feature.start()
+
+ verify(feature).addActionButton(anyInt(), any())
+ verify(toolbar).addBrowserAction(captor.capture())
+
+ val button = captor.value.createView(FrameLayout(testContext))
+ assertEquals(24, (button as ImageButton).drawable.intrinsicHeight)
+ assertEquals(12, button.drawable.intrinsicWidth)
+ }
+
+ @Test
+ fun `action button uses updated url`() {
+ val size = 48
+ val pendingIntent: PendingIntent = mock()
+ val captor = argumentCaptor<Toolbar.ActionButton>()
+ val intentCaptor = argumentCaptor<Intent>()
+
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ actionButtonConfig = CustomTabActionButtonConfig(
+ description = "Button",
+ icon = Bitmap.createBitmap(IntArray(size * size), size, size, Bitmap.Config.ARGB_8888),
+ pendingIntent = pendingIntent,
+ ),
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(CustomTabsToolbarFeature(store, toolbar, sessionId = "mozilla", useCases = useCases) {})
+
+ feature.start()
+
+ store.dispatch(
+ ContentAction.UpdateUrlAction(
+ "mozilla",
+ "https://github.com/mozilla-mobile/android-components",
+ ),
+ ).joinBlocking()
+
+ verify(feature).addActionButton(anyInt(), any())
+ verify(toolbar).addBrowserAction(captor.capture())
+
+ doNothing().`when`(pendingIntent).send(any(), anyInt(), any())
+
+ val button = captor.value.createView(FrameLayout(testContext))
+ button.performClick()
+
+ verify(pendingIntent).send(any(), anyInt(), intentCaptor.capture())
+ assertEquals("https://github.com/mozilla-mobile/android-components", intentCaptor.value.dataString)
+ }
+
+ @Test
+ fun `initialize calls addMenuItems when config has items`() {
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ menuItems = listOf(
+ CustomTabMenuItem("Share", mock()),
+ ),
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(CustomTabsToolbarFeature(store, toolbar, sessionId = "mozilla", useCases = useCases) {})
+
+ feature.start()
+
+ verify(feature).addMenuItems(anyList(), anyInt())
+ }
+
+ @Test
+ fun `initialize calls addMenuItems when menuBuilder has items`() {
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ menuItems = listOf(
+ CustomTabMenuItem("Share", mock()),
+ ),
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(
+ CustomTabsToolbarFeature(
+ store,
+ toolbar,
+ sessionId = "mozilla",
+ useCases = useCases,
+ menuBuilder = BrowserMenuBuilder(listOf(mock(), mock())),
+ ) {},
+ )
+
+ feature.start()
+
+ verify(feature).addMenuItems(anyList(), anyInt())
+ }
+
+ @Test
+ fun `menu items added WITHOUT current items`() {
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ menuItems = listOf(
+ CustomTabMenuItem("Share", mock()),
+ ),
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(
+ CustomTabsToolbarFeature(
+ store,
+ toolbar,
+ sessionId = "mozilla",
+ useCases = useCases,
+ ) {},
+ )
+
+ feature.start()
+
+ val menuBuilder = toolbar.display.menuBuilder
+ assertEquals(1, menuBuilder!!.items.size)
+ }
+
+ @Test
+ fun `menu items added WITH current items`() {
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ menuItems = listOf(
+ CustomTabMenuItem("Share", mock()),
+ ),
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(
+ CustomTabsToolbarFeature(
+ store,
+ toolbar,
+ sessionId = "mozilla",
+ useCases = useCases,
+ menuBuilder = BrowserMenuBuilder(listOf(mock(), mock())),
+ ) {},
+ )
+
+ feature.start()
+
+ val menuBuilder = toolbar.display.menuBuilder
+ assertEquals(3, menuBuilder!!.items.size)
+ }
+
+ @Test
+ fun `menu item added at specified index`() {
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ menuItems = listOf(
+ CustomTabMenuItem("Share", mock()),
+ ),
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(
+ CustomTabsToolbarFeature(
+ store,
+ toolbar,
+ sessionId = "mozilla",
+ useCases = useCases,
+ menuBuilder = BrowserMenuBuilder(listOf(mock(), mock())),
+ menuItemIndex = 1,
+ ) {},
+ )
+
+ feature.start()
+
+ val menuBuilder = toolbar.display.menuBuilder!!
+
+ assertEquals(3, menuBuilder.items.size)
+ assertTrue(menuBuilder.items[1] is SimpleBrowserMenuItem)
+ }
+
+ @Test
+ fun `menu item added appended if index too large`() {
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ menuItems = listOf(
+ CustomTabMenuItem("Share", mock()),
+ ),
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(
+ CustomTabsToolbarFeature(
+ store,
+ toolbar,
+ sessionId = "mozilla",
+ useCases = useCases,
+ menuBuilder = BrowserMenuBuilder(listOf(mock(), mock())),
+ menuItemIndex = 4,
+ ) {},
+ )
+
+ feature.start()
+
+ val menuBuilder = toolbar.display.menuBuilder!!
+
+ assertEquals(3, menuBuilder.items.size)
+ assertTrue(menuBuilder.items[2] is SimpleBrowserMenuItem)
+ }
+
+ @Test
+ fun `menu item added appended if index too small`() {
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ menuItems = listOf(
+ CustomTabMenuItem("Share", mock()),
+ ),
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(
+ CustomTabsToolbarFeature(
+ store,
+ toolbar,
+ sessionId = "mozilla",
+ useCases = useCases,
+ menuBuilder = BrowserMenuBuilder(listOf(mock(), mock())),
+ menuItemIndex = -4,
+ ) {},
+ )
+
+ feature.start()
+
+ val menuBuilder = toolbar.display.menuBuilder!!
+
+ assertEquals(3, menuBuilder.items.size)
+ assertTrue(menuBuilder.items[0] is SimpleBrowserMenuItem)
+ }
+
+ @Test
+ fun `menu item uses updated url`() {
+ val pendingIntent: PendingIntent = mock()
+ val intentCaptor = argumentCaptor<Intent>()
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ menuItems = listOf(
+ CustomTabMenuItem("Share", pendingIntent),
+ ),
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(CustomTabsToolbarFeature(store, toolbar, sessionId = "mozilla", useCases = useCases) {})
+
+ feature.start()
+
+ store.dispatch(
+ ContentAction.UpdateUrlAction(
+ "mozilla",
+ "https://github.com/mozilla-mobile/android-components",
+ ),
+ ).joinBlocking()
+
+ val menuBuilder = toolbar.display.menuBuilder!!
+
+ val item = menuBuilder.items[0]
+
+ val menu: BrowserMenu = mock()
+ val view = TextView(testContext)
+
+ item.bind(menu, view)
+
+ view.performClick()
+
+ doNothing().`when`(pendingIntent).send(any(), anyInt(), any())
+
+ verify(pendingIntent).send(any(), anyInt(), intentCaptor.capture())
+ assertEquals("https://github.com/mozilla-mobile/android-components", intentCaptor.value.dataString)
+ }
+
+ @Test
+ fun `onBackPressed removes initialized session`() {
+ val store = BrowserStore(
+ initialState = BrowserState(
+ customTabs = listOf(
+ createCustomTab("https://www.mozilla.org", id = "mozilla", config = CustomTabConfig()),
+ ),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ var closeExecuted = false
+ val feature = spy(
+ CustomTabsToolbarFeature(
+ store,
+ toolbar,
+ sessionId = "mozilla",
+ useCases = useCases,
+ menuBuilder = BrowserMenuBuilder(listOf(mock(), mock())),
+ menuItemIndex = 4,
+ ) {
+ closeExecuted = true
+ },
+ )
+
+ feature.start()
+
+ val result = feature.onBackPressed()
+
+ assertTrue(result)
+ assertTrue(closeExecuted)
+ }
+
+ @Test
+ fun `onBackPressed without a session does nothing`() {
+ val tab = createCustomTab("https://www.mozilla.org", id = "mozilla", config = CustomTabConfig())
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ var closeExecuted = false
+ val feature = spy(
+ CustomTabsToolbarFeature(
+ store,
+ toolbar,
+ sessionId = null,
+ useCases = useCases,
+ menuBuilder = BrowserMenuBuilder(listOf(mock(), mock())),
+ menuItemIndex = 4,
+ ) {
+ closeExecuted = true
+ },
+ )
+
+ feature.start()
+
+ val result = feature.onBackPressed()
+
+ assertFalse(result)
+ assertFalse(closeExecuted)
+ }
+
+ @Test
+ fun `onBackPressed with uninitialized feature returns false`() {
+ val tab = createCustomTab("https://www.mozilla.org", id = "mozilla", config = CustomTabConfig())
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ var closeExecuted = false
+ val feature = spy(
+ CustomTabsToolbarFeature(
+ store,
+ toolbar,
+ sessionId = null,
+ useCases = useCases,
+ menuBuilder = BrowserMenuBuilder(listOf(mock(), mock())),
+ menuItemIndex = 4,
+ ) {
+ closeExecuted = true
+ },
+ )
+
+ val result = feature.onBackPressed()
+
+ assertFalse(result)
+ assertFalse(closeExecuted)
+ }
+
+ @Test
+ fun `WHEN config toolbar color is dark THEN readableColor is white`() {
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ colorSchemes = ColorSchemes(
+ defaultColorSchemeParams = ColorSchemeParams(toolbarColor = Color.BLACK),
+ ),
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(
+ CustomTabsToolbarFeature(
+ store,
+ toolbar,
+ sessionId = "mozilla",
+ useCases = useCases,
+ menuBuilder = BrowserMenuBuilder(listOf(mock(), mock())),
+ menuItemIndex = 4,
+ ) {},
+ )
+
+ feature.start()
+
+ verify(feature).updateTheme(
+ tab.config.colorSchemes!!.defaultColorSchemeParams!!.toolbarColor,
+ tab.config.colorSchemes!!.defaultColorSchemeParams!!.toolbarColor,
+ tab.config.colorSchemes!!.defaultColorSchemeParams!!.navigationBarDividerColor,
+ Color.WHITE,
+ )
+ verify(feature).addCloseButton(Color.WHITE, tab.config.closeButtonIcon)
+ verify(feature).addActionButton(Color.WHITE, tab.config.actionButtonConfig)
+ assertEquals(Color.WHITE, toolbar.display.colors.text)
+ }
+
+ @Test
+ fun `WHEN config toolbar color is not dark THEN readableColor is black`() {
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(
+ colorSchemes = ColorSchemes(
+ defaultColorSchemeParams = ColorSchemeParams(toolbarColor = Color.WHITE),
+ ),
+ ),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(
+ CustomTabsToolbarFeature(
+ store,
+ toolbar,
+ sessionId = "mozilla",
+ useCases = useCases,
+ menuBuilder = BrowserMenuBuilder(listOf(mock(), mock())),
+ menuItemIndex = 4,
+ ) {},
+ )
+
+ feature.start()
+
+ verify(feature).updateTheme(
+ tab.config.colorSchemes!!.defaultColorSchemeParams!!.toolbarColor,
+ tab.config.colorSchemes!!.defaultColorSchemeParams!!.toolbarColor,
+ tab.config.colorSchemes!!.defaultColorSchemeParams!!.navigationBarDividerColor,
+ Color.BLACK,
+ )
+ verify(feature).addCloseButton(Color.BLACK, tab.config.closeButtonIcon)
+ verify(feature).addActionButton(Color.BLACK, tab.config.actionButtonConfig)
+ }
+
+ @Test
+ fun `WHEN config toolbar has no colour set THEN readableColor uses the toolbar display menu colour`() {
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(
+ CustomTabsToolbarFeature(
+ store,
+ toolbar,
+ sessionId = "mozilla",
+ useCases = useCases,
+ menuBuilder = BrowserMenuBuilder(listOf(mock(), mock())),
+ menuItemIndex = 4,
+ ) {},
+ )
+
+ feature.start()
+
+ verify(feature).updateTheme(
+ tab.config.colorSchemes?.defaultColorSchemeParams?.toolbarColor,
+ tab.config.colorSchemes?.defaultColorSchemeParams?.toolbarColor,
+ tab.config.colorSchemes?.defaultColorSchemeParams?.navigationBarDividerColor,
+ toolbar.display.colors.menu,
+ )
+ verify(feature).addCloseButton(toolbar.display.colors.menu, tab.config.closeButtonIcon)
+ verify(feature).addActionButton(toolbar.display.colors.menu, tab.config.actionButtonConfig)
+ assertEquals(Color.WHITE, toolbar.display.colors.menu)
+ }
+
+ @Test
+ fun `WHEN tab is private THEN readableColor is the default private color`() {
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(showShareMenuItem = true),
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(
+ CustomTabsToolbarFeature(
+ store = store,
+ toolbar = toolbar,
+ sessionId = "mozilla",
+ useCases = useCases,
+ menuBuilder = BrowserMenuBuilder(listOf(mock(), mock())),
+ menuItemIndex = 4,
+ updateTheme = false,
+ ) {},
+ )
+
+ feature.start()
+
+ val colorResId = testContext.theme.resolveAttribute(android.R.attr.textColorPrimary)
+ val privateColor = getColor(testContext, colorResId)
+ verify(feature).addCloseButton(privateColor, tab.config.closeButtonIcon)
+ verify(feature).addActionButton(privateColor, tab.config.actionButtonConfig)
+ verify(feature).addShareButton(privateColor)
+ }
+
+ @Test
+ fun `WHEN COLOR_SCHEME_SYSTEM THEN toNightMode returns MODE_NIGHT_FOLLOW_SYSTEM`() {
+ assertEquals(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, CustomTabsIntent.COLOR_SCHEME_SYSTEM.toNightMode())
+ }
+
+ @Test
+ fun `WHEN COLOR_SCHEME_LIGHT THEN toNightMode returns MODE_NIGHT_NO`() {
+ assertEquals(AppCompatDelegate.MODE_NIGHT_NO, CustomTabsIntent.COLOR_SCHEME_LIGHT.toNightMode())
+ }
+
+ @Test
+ fun `WHEN COLOR_SCHEME_DARK THEN toNightMode returns MODE_NIGHT_YES`() {
+ assertEquals(AppCompatDelegate.MODE_NIGHT_YES, CustomTabsIntent.COLOR_SCHEME_DARK.toNightMode())
+ }
+
+ @Test
+ fun `WHEN unknown color scheme THEN toNightMode returns null`() {
+ assertEquals(null, 100.toNightMode())
+ }
+
+ @Test
+ fun `WHEN no color scheme params set THEN getConfiguredColorSchemeParams returns null `() {
+ val customTabConfig = CustomTabConfig()
+ assertEquals(null, customTabConfig.colorSchemes?.getConfiguredColorSchemeParams())
+ }
+
+ @Test
+ fun `WHEN only default color scheme params set THEN getConfiguredColorSchemeParams returns default `() {
+ val customTabConfig = CustomTabConfig(
+ colorSchemes = ColorSchemes(
+ defaultColorSchemeParams = defaultColorSchemeParams,
+ ),
+ )
+
+ assertEquals(
+ defaultColorSchemeParams,
+ customTabConfig.colorSchemes!!.getConfiguredColorSchemeParams(),
+ )
+ }
+
+ @Test
+ fun `WHEN night mode follow system and is light mode THEN getConfiguredColorSchemeParams returns light color scheme`() {
+ val customTabConfig = CustomTabConfig(
+ colorSchemes = ColorSchemes(
+ defaultColorSchemeParams = defaultColorSchemeParams,
+ lightColorSchemeParams = lightColorSchemeParams,
+ darkColorSchemeParams = darkColorSchemeParams,
+ ),
+ )
+
+ assertEquals(
+ lightColorSchemeParams,
+ customTabConfig.colorSchemes!!.getConfiguredColorSchemeParams(
+ nightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN night mode follow system, is light mode no light color scheme THEN getConfiguredColorSchemeParams returns default scheme`() {
+ val customTabConfig = CustomTabConfig(
+ colorSchemes = ColorSchemes(
+ defaultColorSchemeParams = defaultColorSchemeParams,
+ darkColorSchemeParams = darkColorSchemeParams,
+ ),
+ )
+
+ assertEquals(
+ defaultColorSchemeParams,
+ customTabConfig.colorSchemes!!.getConfiguredColorSchemeParams(
+ nightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN night mode follow system and is dark mode THEN getConfiguredColorSchemeParams returns dark color scheme`() {
+ val customTabConfig = CustomTabConfig(
+ colorSchemes = ColorSchemes(
+ defaultColorSchemeParams = defaultColorSchemeParams,
+ lightColorSchemeParams = lightColorSchemeParams,
+ darkColorSchemeParams = darkColorSchemeParams,
+ ),
+ )
+
+ assertEquals(
+ darkColorSchemeParams,
+ customTabConfig.colorSchemes!!.getConfiguredColorSchemeParams(
+ nightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
+ isDarkMode = true,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN night mode follow system, is dark mode no dark color scheme THEN getConfiguredColorSchemeParams returns default scheme`() {
+ val customTabConfig = CustomTabConfig(
+ colorSchemes = ColorSchemes(
+ defaultColorSchemeParams = defaultColorSchemeParams,
+ lightColorSchemeParams = lightColorSchemeParams,
+ ),
+ )
+
+ assertEquals(
+ defaultColorSchemeParams,
+ customTabConfig.colorSchemes!!.getConfiguredColorSchemeParams(
+ nightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
+ isDarkMode = true,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN night mode no THEN getConfiguredColorSchemeParams returns light color scheme`() {
+ val customTabConfig = CustomTabConfig(
+ colorSchemes = ColorSchemes(
+ defaultColorSchemeParams = defaultColorSchemeParams,
+ lightColorSchemeParams = lightColorSchemeParams,
+ darkColorSchemeParams = darkColorSchemeParams,
+ ),
+ )
+
+ assertEquals(
+ lightColorSchemeParams,
+ customTabConfig.colorSchemes!!.getConfiguredColorSchemeParams(
+ nightMode = AppCompatDelegate.MODE_NIGHT_NO,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN night mode no & no light color params THEN getConfiguredColorSchemeParams returns default color scheme`() {
+ val customTabConfig = CustomTabConfig(
+ colorSchemes = ColorSchemes(
+ defaultColorSchemeParams = defaultColorSchemeParams,
+ darkColorSchemeParams = darkColorSchemeParams,
+ ),
+ )
+
+ assertEquals(
+ defaultColorSchemeParams,
+ customTabConfig.colorSchemes!!.getConfiguredColorSchemeParams(
+ nightMode = AppCompatDelegate.MODE_NIGHT_NO,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN night mode yes THEN getConfiguredColorSchemeParams returns dark color scheme`() {
+ val customTabConfig = CustomTabConfig(
+ colorSchemes = ColorSchemes(
+ defaultColorSchemeParams = defaultColorSchemeParams,
+ lightColorSchemeParams = lightColorSchemeParams,
+ darkColorSchemeParams = darkColorSchemeParams,
+ ),
+ )
+
+ assertEquals(
+ darkColorSchemeParams,
+ customTabConfig.colorSchemes!!.getConfiguredColorSchemeParams(
+ nightMode = AppCompatDelegate.MODE_NIGHT_YES,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN night mode yes & no dark color params THEN getConfiguredColorSchemeParams returns default color scheme`() {
+ val customTabConfig = CustomTabConfig(
+ colorSchemes = ColorSchemes(
+ defaultColorSchemeParams = defaultColorSchemeParams,
+ lightColorSchemeParams = lightColorSchemeParams,
+ ),
+ )
+
+ assertEquals(
+ defaultColorSchemeParams,
+ customTabConfig.colorSchemes!!.getConfiguredColorSchemeParams(
+ nightMode = AppCompatDelegate.MODE_NIGHT_YES,
+ ),
+ )
+ }
+
+ @Test
+ fun `WHEN night mode not set THEN getConfiguredColorSchemeParams returns default color scheme`() {
+ val customTabConfig = CustomTabConfig(
+ colorSchemes = ColorSchemes(
+ defaultColorSchemeParams = defaultColorSchemeParams,
+ lightColorSchemeParams = lightColorSchemeParams,
+ darkColorSchemeParams = darkColorSchemeParams,
+ ),
+ )
+
+ assertEquals(
+ defaultColorSchemeParams,
+ customTabConfig.colorSchemes!!.getConfiguredColorSchemeParams(),
+ )
+ }
+
+ @Test
+ fun `WHEN ColorSchemeParams has all properties THEN withDefault returns the same ColorSchemeParams`() {
+ val result = lightColorSchemeParams.withDefault(defaultColorSchemeParams)
+
+ assertEquals(lightColorSchemeParams, result)
+ }
+
+ @Test
+ fun `WHEN ColorSchemeParams has some properties THEN withDefault uses default for the missing properties`() {
+ val colorSchemeParams = ColorSchemeParams(
+ toolbarColor = Color.BLACK,
+ navigationBarDividerColor = Color.YELLOW,
+ )
+
+ val expected = ColorSchemeParams(
+ toolbarColor = colorSchemeParams.toolbarColor,
+ secondaryToolbarColor = defaultColorSchemeParams.secondaryToolbarColor,
+ navigationBarColor = defaultColorSchemeParams.navigationBarColor,
+ navigationBarDividerColor = colorSchemeParams.navigationBarDividerColor,
+ )
+
+ val result = colorSchemeParams.withDefault(defaultColorSchemeParams)
+
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `WHEN ColorSchemeParams has no properties THEN withDefault returns all default ColorSchemeParams`() {
+ val result = ColorSchemeParams().withDefault(defaultColorSchemeParams)
+
+ assertEquals(defaultColorSchemeParams, result)
+ }
+
+ @Test
+ fun `show title only if not empty`() {
+ val dispatcher = UnconfinedTestDispatcher()
+ Dispatchers.setMain(dispatcher)
+
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(),
+ title = "",
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(
+ CustomTabsToolbarFeature(
+ store,
+ toolbar,
+ sessionId = "mozilla",
+ useCases = useCases,
+ menuBuilder = BrowserMenuBuilder(listOf(mock(), mock())),
+ menuItemIndex = 4,
+ ) {},
+ )
+
+ feature.start()
+
+ assertEquals("", toolbar.title)
+
+ store.dispatch(
+ ContentAction.UpdateTitleAction(
+ "mozilla",
+ "Internet for people, not profit - Mozilla",
+ ),
+ ).joinBlocking()
+
+ assertEquals("Internet for people, not profit - Mozilla", toolbar.title)
+
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `Will use URL as title if title was shown once and is now empty`() {
+ val dispatcher = UnconfinedTestDispatcher()
+ Dispatchers.setMain(dispatcher)
+
+ val tab = createCustomTab(
+ "https://www.mozilla.org",
+ id = "mozilla",
+ config = CustomTabConfig(),
+ title = "",
+ )
+ val store = BrowserStore(
+ BrowserState(
+ customTabs = listOf(tab),
+ ),
+ )
+ val toolbar = spy(BrowserToolbar(testContext))
+ val useCases = CustomTabsUseCases(
+ store = store,
+ loadUrlUseCase = SessionUseCases(store).loadUrl,
+ )
+ val feature = spy(
+ CustomTabsToolbarFeature(
+ store,
+ toolbar,
+ sessionId = "mozilla",
+ useCases = useCases,
+ menuBuilder = BrowserMenuBuilder(listOf(mock(), mock())),
+ menuItemIndex = 4,
+ ) {},
+ )
+
+ feature.start()
+
+ feature.start()
+
+ assertEquals("", toolbar.title)
+
+ store.dispatch(
+ ContentAction.UpdateUrlAction("mozilla", "https://www.mozilla.org/en-US/firefox/"),
+ ).joinBlocking()
+
+ assertEquals("", toolbar.title)
+
+ store.dispatch(
+ ContentAction.UpdateTitleAction(
+ "mozilla",
+ "Firefox - Protect your life online with privacy-first products",
+ ),
+ ).joinBlocking()
+
+ assertEquals("Firefox - Protect your life online with privacy-first products", toolbar.title)
+
+ store.dispatch(
+ ContentAction.UpdateUrlAction("mozilla", "https://github.com/mozilla-mobile/android-components"),
+ ).joinBlocking()
+
+ assertEquals("https://github.com/mozilla-mobile/android-components", toolbar.title)
+
+ store.dispatch(
+ ContentAction.UpdateTitleAction("mozilla", "Le GitHub"),
+ ).joinBlocking()
+
+ assertEquals("Le GitHub", toolbar.title)
+
+ store.dispatch(
+ ContentAction.UpdateUrlAction("mozilla", "https://github.com/mozilla-mobile/fenix"),
+ ).joinBlocking()
+
+ assertEquals("https://github.com/mozilla-mobile/fenix", toolbar.title)
+
+ store.dispatch(
+ ContentAction.UpdateTitleAction("mozilla", ""),
+ ).joinBlocking()
+
+ assertEquals("https://github.com/mozilla-mobile/fenix", toolbar.title)
+
+ store.dispatch(
+ ContentAction.UpdateTitleAction(
+ "mozilla",
+ "A collection of Android libraries to build browsers or browser-like applications.",
+ ),
+ ).joinBlocking()
+
+ assertEquals("A collection of Android libraries to build browsers or browser-like applications.", toolbar.title)
+
+ store.dispatch(
+ ContentAction.UpdateTitleAction("mozilla", ""),
+ ).joinBlocking()
+
+ assertEquals("https://github.com/mozilla-mobile/fenix", toolbar.title)
+ }
+
+ private fun extractActionView(
+ browserToolbar: BrowserToolbar,
+ contentDescription: String,
+ ): ImageButton? {
+ var actionView: ImageButton? = null
+
+ browserToolbar.forEach { group ->
+ val viewGroup = group as ViewGroup
+
+ viewGroup.forEach inner@{ subGroup ->
+ if (subGroup is ViewGroup) {
+ subGroup.forEach {
+ if (it is ImageButton && it.contentDescription == contentDescription) {
+ actionView = it
+ return@inner
+ }
+ }
+ }
+ }
+ }
+
+ return actionView
+ }
+
+ private val defaultColorSchemeParams = ColorSchemeParams(
+ toolbarColor = Color.CYAN,
+ secondaryToolbarColor = Color.GREEN,
+ navigationBarColor = Color.WHITE,
+ navigationBarDividerColor = Color.MAGENTA,
+ )
+
+ private val lightColorSchemeParams = ColorSchemeParams(
+ toolbarColor = Color.BLACK,
+ secondaryToolbarColor = Color.RED,
+ navigationBarColor = Color.BLUE,
+ navigationBarDividerColor = Color.YELLOW,
+ )
+
+ private val darkColorSchemeParams = ColorSchemeParams(
+ toolbarColor = Color.DKGRAY,
+ secondaryToolbarColor = Color.LTGRAY,
+ navigationBarColor = Color.GRAY,
+ navigationBarDividerColor = Color.WHITE,
+ )
+}
diff --git a/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/feature/CustomTabSessionTitleObserverTest.kt b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/feature/CustomTabSessionTitleObserverTest.kt
new file mode 100644
index 0000000000..c918442593
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/feature/CustomTabSessionTitleObserverTest.kt
@@ -0,0 +1,113 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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.customtabs.feature
+
+import mozilla.components.browser.state.state.CustomTabSessionState
+import mozilla.components.browser.state.state.createCustomTab
+import mozilla.components.concept.toolbar.AutocompleteDelegate
+import mozilla.components.concept.toolbar.Toolbar
+import mozilla.components.support.test.ThrowProperty
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+
+class CustomTabSessionTitleObserverTest {
+
+ @Test
+ fun `show title only if not empty`() {
+ val toolbar: Toolbar = mock()
+ val observer = CustomTabSessionTitleObserver(toolbar)
+ val url = "https://www.mozilla.org"
+ val title = "Internet for people, not profit - Mozilla"
+
+ observer.onTab(createCustomTab(url, title = ""))
+ verify(toolbar, never()).title = ""
+
+ observer.onTab(createCustomTab(url, title = title))
+ verify(toolbar).title = title
+ }
+
+ @Test
+ fun `Will use URL as title if title was shown once and is now empty`() {
+ val toolbar = MockToolbar()
+ var tab = createCustomTab("https://mozilla.org")
+ val observer = CustomTabSessionTitleObserver(toolbar)
+
+ observer.onTab(tab)
+ assertEquals("", toolbar.title)
+
+ tab = tab.withUrl("https://www.mozilla.org/en-US/firefox/")
+ observer.onTab(tab)
+ assertEquals("", toolbar.title)
+
+ tab = tab.withTitle("Firefox - Protect your life online with privacy-first products")
+ observer.onTab(tab)
+ assertEquals("Firefox - Protect your life online with privacy-first products", toolbar.title)
+
+ tab = tab.withUrl("https://github.com/mozilla-mobile/android-components")
+ observer.onTab(tab)
+ assertEquals("Firefox - Protect your life online with privacy-first products", toolbar.title)
+
+ tab = tab.withTitle("")
+ observer.onTab(tab)
+ assertEquals("https://github.com/mozilla-mobile/android-components", toolbar.title)
+
+ tab = tab.withTitle("A collection of Android libraries to build browsers or browser-like applications.")
+ observer.onTab(tab)
+ assertEquals("A collection of Android libraries to build browsers or browser-like applications.", toolbar.title)
+
+ tab = tab.withTitle("")
+ observer.onTab(tab)
+ assertEquals("https://github.com/mozilla-mobile/android-components", toolbar.title)
+ }
+
+ private class MockToolbar : Toolbar {
+ override var title: String = ""
+ override var highlight: Toolbar.Highlight = Toolbar.Highlight.NONE
+ override var url: CharSequence by ThrowProperty()
+ override var private: Boolean by ThrowProperty()
+ override var siteSecure: Toolbar.SiteSecurity by ThrowProperty()
+ override var siteTrackingProtection: Toolbar.SiteTrackingProtection by ThrowProperty()
+ override fun setSearchTerms(searchTerms: String) = Unit
+ override fun displayProgress(progress: Int) = Unit
+ override fun onBackPressed(): Boolean = false
+ override fun onStop() = Unit
+ override fun setOnUrlCommitListener(listener: (String) -> Boolean) = Unit
+ override fun setAutocompleteListener(filter: suspend (String, AutocompleteDelegate) -> Unit) = Unit
+ override fun addBrowserAction(action: Toolbar.Action) = Unit
+ override fun removeBrowserAction(action: Toolbar.Action) = Unit
+ override fun invalidateActions() = Unit
+ override fun addPageAction(action: Toolbar.Action) = Unit
+ override fun removePageAction(action: Toolbar.Action) = Unit
+ override fun addNavigationAction(action: Toolbar.Action) = Unit
+ override fun removeNavigationAction(action: Toolbar.Action) = Unit
+ override fun addEditActionStart(action: Toolbar.Action) = Unit
+ override fun addEditActionEnd(action: Toolbar.Action) = Unit
+ override fun removeEditActionEnd(action: Toolbar.Action) = Unit
+ override fun hideMenuButton() = Unit
+ override fun showMenuButton() = Unit
+ override fun setDisplayHorizontalPadding(horizontalPadding: Int) = Unit
+ override fun hidePageActionSeparator() = Unit
+ override fun showPageActionSeparator() = Unit
+ override fun setOnEditListener(listener: Toolbar.OnEditListener) = Unit
+ override fun displayMode() = Unit
+ override fun editMode(cursorPlacement: Toolbar.CursorPlacement) = Unit
+ override fun dismissMenu() = Unit
+ override fun enableScrolling() = Unit
+ override fun disableScrolling() = Unit
+ override fun collapse() = Unit
+ override fun expand() = Unit
+ }
+}
+
+private fun CustomTabSessionState.withTitle(title: String) = copy(
+ content = content.copy(title = title),
+)
+
+private fun CustomTabSessionState.withUrl(url: String) = copy(
+ content = content.copy(url = url),
+)
diff --git a/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/feature/OriginVerifierFeatureTest.kt b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/feature/OriginVerifierFeatureTest.kt
new file mode 100644
index 0000000000..e035349879
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/feature/OriginVerifierFeatureTest.kt
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.customtabs.feature
+
+import androidx.browser.customtabs.CustomTabsService.RELATION_HANDLE_ALL_URLS
+import androidx.browser.customtabs.CustomTabsService.RELATION_USE_AS_ORIGIN
+import androidx.browser.customtabs.CustomTabsSessionToken
+import androidx.core.net.toUri
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import mozilla.components.feature.customtabs.store.CustomTabState
+import mozilla.components.feature.customtabs.store.CustomTabsServiceStore
+import mozilla.components.feature.customtabs.store.OriginRelationPair
+import mozilla.components.feature.customtabs.store.ValidateRelationshipAction
+import mozilla.components.feature.customtabs.store.VerificationStatus.FAILURE
+import mozilla.components.feature.customtabs.store.VerificationStatus.PENDING
+import mozilla.components.feature.customtabs.store.VerificationStatus.SUCCESS
+import mozilla.components.feature.customtabs.verify.OriginVerifier
+import mozilla.components.support.test.any
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.Mockito.anyString
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+@ExperimentalCoroutinesApi
+class OriginVerifierFeatureTest {
+
+ @Test
+ fun `verify fails if no creatorPackageName is saved`() = runTest {
+ val feature = OriginVerifierFeature(mock(), mock(), mock())
+
+ assertFalse(feature.verify(CustomTabState(), mock(), RELATION_HANDLE_ALL_URLS, mock()))
+ }
+
+ @Test
+ fun `verify returns existing relationship`() = runTest {
+ val feature = OriginVerifierFeature(mock(), mock(), mock())
+ val origin = "https://example.com".toUri()
+ val state = CustomTabState(
+ creatorPackageName = "com.example.twa",
+ relationships = mapOf(
+ OriginRelationPair(origin, RELATION_HANDLE_ALL_URLS) to SUCCESS,
+ OriginRelationPair(origin, RELATION_USE_AS_ORIGIN) to FAILURE,
+ OriginRelationPair("https://sample.com".toUri(), RELATION_HANDLE_ALL_URLS) to PENDING,
+ ),
+ )
+
+ assertTrue(feature.verify(state, mock(), RELATION_HANDLE_ALL_URLS, origin))
+ assertFalse(feature.verify(state, mock(), RELATION_USE_AS_ORIGIN, origin))
+ }
+
+ @Test
+ fun `verify checks new relationships`() = runTest {
+ val store: CustomTabsServiceStore = mock()
+ val verifier: OriginVerifier = mock()
+ val feature = spy(OriginVerifierFeature(mock(), mock()) { store.dispatch(it) })
+ doReturn(verifier).`when`(feature).getVerifier(anyString(), anyInt())
+ doReturn(true).`when`(verifier).verifyOrigin(any())
+
+ val token: CustomTabsSessionToken = mock()
+ val origin = "https://sample.com".toUri()
+ val state = CustomTabState(creatorPackageName = "com.example.twa")
+ assertNotNull(state)
+
+ assertTrue(feature.verify(state, token, RELATION_HANDLE_ALL_URLS, origin))
+
+ verify(verifier).verifyOrigin(origin)
+ verify(store).dispatch(ValidateRelationshipAction(token, RELATION_HANDLE_ALL_URLS, origin, PENDING))
+ verify(store).dispatch(ValidateRelationshipAction(token, RELATION_HANDLE_ALL_URLS, origin, SUCCESS))
+ }
+}
diff --git a/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/menu/CustomTabMenuCandidatesTest.kt b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/menu/CustomTabMenuCandidatesTest.kt
new file mode 100644
index 0000000000..1f2e42ec1a
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/menu/CustomTabMenuCandidatesTest.kt
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.feature.customtabs.menu
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.core.net.toUri
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.browser.state.state.CustomTabConfig
+import mozilla.components.browser.state.state.CustomTabMenuItem
+import mozilla.components.browser.state.state.createCustomTab
+import mozilla.components.concept.menu.candidate.MenuCandidate
+import mozilla.components.support.test.argumentCaptor
+import mozilla.components.support.test.eq
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.Mockito.verify
+
+@RunWith(AndroidJUnit4::class)
+class CustomTabMenuCandidatesTest {
+
+ @Test
+ fun `return an empty list if there are no menu items`() {
+ val customTabSessionState = createCustomTab(
+ url = "https://mozilla.org",
+ config = CustomTabConfig(menuItems = emptyList()),
+ )
+
+ assertEquals(
+ emptyList<MenuCandidate>(),
+ customTabSessionState.createCustomTabMenuCandidates(mock()),
+ )
+ }
+
+ @Test
+ fun `create a candidate for each menu item`() {
+ val pendingIntent1 = mock<PendingIntent>()
+ val pendingIntent2 = mock<PendingIntent>()
+ val customTabSessionState = createCustomTab(
+ url = "https://mozilla.org",
+ config = CustomTabConfig(
+ menuItems = listOf(
+ CustomTabMenuItem(
+ name = "item1",
+ pendingIntent = pendingIntent1,
+ ),
+ CustomTabMenuItem(
+ name = "item2",
+ pendingIntent = pendingIntent2,
+ ),
+ ),
+ ),
+ )
+
+ val context = mock<Context>()
+ val intent = argumentCaptor<Intent>()
+ val menuCandidates = customTabSessionState.createCustomTabMenuCandidates(context)
+
+ assertEquals(2, menuCandidates.size)
+ assertEquals("item1", menuCandidates[0].text)
+ assertEquals("item2", menuCandidates[1].text)
+
+ menuCandidates[0].onClick()
+ verify(pendingIntent1).send(eq(context), anyInt(), intent.capture())
+ assertEquals("https://mozilla.org".toUri(), intent.value.data)
+
+ menuCandidates[1].onClick()
+ verify(pendingIntent2).send(eq(context), anyInt(), intent.capture())
+ assertEquals("https://mozilla.org".toUri(), intent.value.data)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/store/CustomTabsServiceStateReducerTest.kt b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/store/CustomTabsServiceStateReducerTest.kt
new file mode 100644
index 0000000000..bfee3db851
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/store/CustomTabsServiceStateReducerTest.kt
@@ -0,0 +1,174 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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.customtabs.store
+
+import androidx.browser.customtabs.CustomTabsService.RELATION_HANDLE_ALL_URLS
+import androidx.browser.customtabs.CustomTabsService.RELATION_USE_AS_ORIGIN
+import androidx.browser.customtabs.CustomTabsSessionToken
+import androidx.core.net.toUri
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import mozilla.components.support.test.mock
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class CustomTabsServiceStateReducerTest {
+
+ @Test
+ fun `reduce adds new tab to map`() {
+ val token: CustomTabsSessionToken = mock()
+ val initialState = CustomTabsServiceState()
+ val action = SaveCreatorPackageNameAction(token, "com.example.twa")
+
+ assertEquals(
+ CustomTabsServiceState(
+ tabs = mapOf(
+ token to CustomTabState(creatorPackageName = "com.example.twa"),
+ ),
+ ),
+ CustomTabsServiceStateReducer.reduce(initialState, action),
+ )
+ }
+
+ @Test
+ fun `reduce replaces existing tab in map`() {
+ val token: CustomTabsSessionToken = mock()
+ val initialState = CustomTabsServiceState(
+ tabs = mapOf(
+ token to CustomTabState(creatorPackageName = "com.example.twa"),
+ ),
+ )
+ val action = SaveCreatorPackageNameAction(token, "com.example.trusted.web.app")
+
+ assertEquals(
+ CustomTabsServiceState(
+ tabs = mapOf(
+ token to CustomTabState(creatorPackageName = "com.example.trusted.web.app"),
+ ),
+ ),
+ CustomTabsServiceStateReducer.reduce(initialState, action),
+ )
+ }
+
+ @Test
+ fun `reduce adds new relationship`() {
+ val token: CustomTabsSessionToken = mock()
+ val initialState = CustomTabsServiceState(
+ tabs = mapOf(
+ token to CustomTabState(creatorPackageName = "com.example.twa"),
+ ),
+ )
+ val action = ValidateRelationshipAction(
+ token,
+ RELATION_HANDLE_ALL_URLS,
+ "https://example.com".toUri(),
+ VerificationStatus.PENDING,
+ )
+
+ assertEquals(
+ CustomTabsServiceState(
+ tabs = mapOf(
+ token to CustomTabState(
+ creatorPackageName = "com.example.twa",
+ relationships = mapOf(
+ Pair(
+ OriginRelationPair("https://example.com".toUri(), RELATION_HANDLE_ALL_URLS),
+ VerificationStatus.PENDING,
+ ),
+ ),
+ ),
+ ),
+ ),
+ CustomTabsServiceStateReducer.reduce(initialState, action),
+ )
+ }
+
+ @Test
+ fun `reduce adds new relationship of different type`() {
+ val token: CustomTabsSessionToken = mock()
+ val initialState = CustomTabsServiceState(
+ tabs = mapOf(
+ token to CustomTabState(
+ creatorPackageName = "com.example.twa",
+ relationships = mapOf(
+ Pair(
+ OriginRelationPair("https://example.com".toUri(), RELATION_HANDLE_ALL_URLS),
+ VerificationStatus.FAILURE,
+ ),
+ ),
+ ),
+ ),
+ )
+ val action = ValidateRelationshipAction(
+ token,
+ RELATION_USE_AS_ORIGIN,
+ "https://example.com".toUri(),
+ VerificationStatus.PENDING,
+ )
+
+ assertEquals(
+ CustomTabsServiceState(
+ tabs = mapOf(
+ token to CustomTabState(
+ creatorPackageName = "com.example.twa",
+ relationships = mapOf(
+ Pair(
+ OriginRelationPair("https://example.com".toUri(), RELATION_HANDLE_ALL_URLS),
+ VerificationStatus.FAILURE,
+ ),
+ Pair(
+ OriginRelationPair("https://example.com".toUri(), RELATION_USE_AS_ORIGIN),
+ VerificationStatus.PENDING,
+ ),
+ ),
+ ),
+ ),
+ ),
+ CustomTabsServiceStateReducer.reduce(initialState, action),
+ )
+ }
+
+ @Test
+ fun `reduce replaces existing relationship`() {
+ val token: CustomTabsSessionToken = mock()
+ val initialState = CustomTabsServiceState(
+ tabs = mapOf(
+ token to CustomTabState(
+ creatorPackageName = "com.example.twa",
+ relationships = mapOf(
+ Pair(
+ OriginRelationPair("https://example.com".toUri(), RELATION_HANDLE_ALL_URLS),
+ VerificationStatus.PENDING,
+ ),
+ ),
+ ),
+ ),
+ )
+ val action = ValidateRelationshipAction(
+ token,
+ RELATION_HANDLE_ALL_URLS,
+ "https://example.com".toUri(),
+ VerificationStatus.SUCCESS,
+ )
+
+ assertEquals(
+ CustomTabsServiceState(
+ tabs = mapOf(
+ token to CustomTabState(
+ creatorPackageName = "com.example.twa",
+ relationships = mapOf(
+ Pair(
+ OriginRelationPair("https://example.com".toUri(), RELATION_HANDLE_ALL_URLS),
+ VerificationStatus.SUCCESS,
+ ),
+ ),
+ ),
+ ),
+ ),
+ CustomTabsServiceStateReducer.reduce(initialState, action),
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/verify/OriginVerifierTest.kt b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/verify/OriginVerifierTest.kt
new file mode 100644
index 0000000000..5e6e384af4
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/test/java/mozilla/components/feature/customtabs/verify/OriginVerifierTest.kt
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.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.customtabs.verify
+
+import android.content.pm.PackageManager
+import androidx.browser.customtabs.CustomTabsService.RELATION_HANDLE_ALL_URLS
+import androidx.browser.customtabs.CustomTabsService.RELATION_USE_AS_ORIGIN
+import androidx.core.net.toUri
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import mozilla.components.concept.fetch.Response
+import mozilla.components.service.digitalassetlinks.AssetDescriptor
+import mozilla.components.service.digitalassetlinks.Relation
+import mozilla.components.service.digitalassetlinks.RelationChecker
+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.spy
+import org.mockito.MockitoAnnotations.openMocks
+
+@RunWith(AndroidJUnit4::class)
+@ExperimentalCoroutinesApi
+class OriginVerifierTest {
+
+ private val androidAsset = AssetDescriptor.Android(
+ packageName = "com.app.name",
+ sha256CertFingerprint = "AA:BB:CC:10:20:30:01:02",
+ )
+
+ @Mock private lateinit var packageManager: PackageManager
+
+ @Mock private lateinit var response: Response
+
+ @Mock private lateinit var body: Response.Body
+
+ @Mock private lateinit var checker: RelationChecker
+
+ @Suppress("Deprecation")
+ @Before
+ fun setup() {
+ openMocks(this)
+
+ doReturn(body).`when`(response).body
+ doReturn(200).`when`(response).status
+ doReturn("{\"linked\":true}").`when`(body).string()
+ }
+
+ @Test
+ fun `only HTTPS allowed`() = runTest {
+ val verifier = buildVerifier(RELATION_HANDLE_ALL_URLS)
+ assertFalse(verifier.verifyOrigin("LOL".toUri()))
+ assertFalse(verifier.verifyOrigin("http://www.android.com".toUri()))
+ }
+
+ @Test
+ fun verifyOrigin() = runTest {
+ val verifier = buildVerifier(RELATION_USE_AS_ORIGIN)
+ doReturn(true).`when`(checker).checkRelationship(
+ AssetDescriptor.Web("https://www.example.com"),
+ Relation.USE_AS_ORIGIN,
+ androidAsset,
+ )
+ assertTrue(verifier.verifyOrigin("https://www.example.com".toUri()))
+ }
+
+ private fun buildVerifier(relation: Int): OriginVerifier {
+ val verifier = spy(
+ OriginVerifier(
+ "com.app.name",
+ relation,
+ packageManager,
+ checker,
+ ),
+ )
+ doReturn(androidAsset).`when`(verifier).androidAsset
+ return verifier
+ }
+}
diff --git a/mobile/android/android-components/components/feature/customtabs/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/feature/customtabs/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..49324d83c5
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1,3 @@
+mock-maker-inline
+// This allows mocking final classes (classes are final by default in Kotlin)
+
diff --git a/mobile/android/android-components/components/feature/customtabs/src/test/resources/robolectric.properties b/mobile/android/android-components/components/feature/customtabs/src/test/resources/robolectric.properties
new file mode 100644
index 0000000000..932b01b9eb
--- /dev/null
+++ b/mobile/android/android-components/components/feature/customtabs/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=28