Verified Commit b7ef7a3d authored by mimi89999's avatar mimi89999 Committed by Pier Angelo Vendrame
Browse files

Bug 1961829 - Only color HTTP(S) URIs and fallback to coloring the host in...

Bug 1961829 - Only color HTTP(S) URIs and fallback to coloring the host in Android toolbar URLRenderer. r=tthibaud,android-reviewers

Differential Revision: https://phabricator.services.mozilla.com/D248132
parent 5fe142be
Loading
Loading
Loading
Loading
+5 −3
Original line number Diff line number Diff line
@@ -88,11 +88,13 @@ class ToolbarFeature(
    )

    /**
     * Controls how the url should be styled
     * Controls how the URL should be styled
     *
     * RegistrableDomain: displays only the eTLD+1 (direct subdomain of the public suffix), uncolored
     * ColoredUrl: displays the registrableDomain with color and url with another color
     * UncoloredUrl: displays the full url, uncolored
     * ColoredUrl: displays the full URL with distinct colors for the registrable domain and the rest of the URL.
     *   Colors the entire hostname if the registrable domain cannot be determined or is an IP address.
     *   Leaves non http(s) URLs uncolored.
     * UncoloredUrl: displays the full URL, uncolored
     */
    sealed class RenderStyle {
        object RegistrableDomain : RenderStyle()
+81 −28
Original line number Diff line number Diff line
@@ -18,6 +18,9 @@ import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.launch
import mozilla.components.concept.toolbar.Toolbar
import mozilla.components.feature.toolbar.ToolbarFeature
import mozilla.components.lib.publicsuffixlist.PublicSuffixList
import mozilla.components.support.ktx.android.net.isHttpOrHttps
import mozilla.components.support.ktx.kotlin.isIpv4OrIpv6

/**
 * Asynchronous URL renderer.
@@ -78,8 +81,15 @@ internal class URLRenderer(
            }
            // Display the registrableDomain with color and URL with another color
            ToolbarFeature.RenderStyle.ColoredUrl -> SpannableStringBuilder(url).apply {
                color(configuration.urlColor)
                colorRegistrableDomain(configuration)
                val span = getRegistrableDomainOrHostSpan(url, configuration.publicSuffixList)

                if (configuration.urlColor != null && span != null) {
                    applyUrlColors(
                        configuration.urlColor,
                        configuration.registrableDomainColor,
                        span,
                    )
                }
            }
            // Display the full URL, uncolored
            ToolbarFeature.RenderStyle.UncoloredUrl -> url
@@ -90,40 +100,83 @@ internal class URLRenderer(
private suspend fun getRegistrableDomain(host: String, configuration: ToolbarFeature.UrlRenderConfiguration) =
    configuration.publicSuffixList.getPublicSuffixPlusOne(host).await()

private suspend fun SpannableStringBuilder.colorRegistrableDomain(
    configuration: ToolbarFeature.UrlRenderConfiguration,
) {
    val url = toString()
    val host = url.toUri().host?.removeSuffix(".") ?: return

    val registrableDomain = configuration
        .publicSuffixList
        .getPublicSuffixPlusOne(host)
        .await() ?: return

    val indexOfHost = url.indexOf(host)
    val indexOfRegistrableDomain = host.lastIndexOf(registrableDomain)
    if (indexOfHost == -1 || indexOfRegistrableDomain == -1) {
        return
/**
 * Determines the position span of the registrable domain within a host string.
 *
 * @param host The host string to analyze
 * @param publicSuffixList The [PublicSuffixList] used to get the eTLD+1 for the host
 * @return A Pair of (startIndex, endIndex) for the registrable domain within the host,
 *         or null if the host is an IP address or no registrable domain could be found
 */
@VisibleForTesting
internal suspend fun getRegistrableDomainSpanInHost(
    host: String,
    publicSuffixList: PublicSuffixList,
): Pair<Int, Int>? {
    if (host.isIpv4OrIpv6()) return null

    val normalizedHost = host.removeSuffix(".")

    val registrableDomain = publicSuffixList
        .getPublicSuffixPlusOne(normalizedHost)
        .await() ?: return null

    val start = normalizedHost.lastIndexOf(registrableDomain)
    return if (start == -1) {
        null
    } else {
        start to start + registrableDomain.length
    }

    val index = indexOfHost + indexOfRegistrableDomain

    setSpan(
        ForegroundColorSpan(configuration.registrableDomainColor),
        index,
        index + registrableDomain.length,
        SPAN_INCLUSIVE_INCLUSIVE,
    )
}

private fun SpannableStringBuilder.color(@ColorInt urlColor: Int?) {
    urlColor ?: return
/**
 * Determines the position span of either the registrable domain or the full host
 * within a URL string.
 *
 * @param url The complete URL to analyze
 * @param publicSuffixList The [PublicSuffixList] used to get the eTLD+1 for the host
 * @return A Pair of (startIndex, endIndex) for either:
 *         - The registrable domain's position within the URL, or
 *         - The host's position within the URL if no registrable domain was found, or
 *         - null if the URL has no host or the host couldn't be located in the URL
 */
@Suppress("ReturnCount")
@VisibleForTesting
internal suspend fun getRegistrableDomainOrHostSpan(
    url: String,
    publicSuffixList: PublicSuffixList,
): Pair<Int, Int>? {
    val uri = url.toUri()
    if (!uri.isHttpOrHttps) return null

    val host = uri.host ?: return null

    val hostStart = url.indexOf(host)
    if (hostStart == -1) return null

    val domainSpan = getRegistrableDomainSpanInHost(host, publicSuffixList)
    return domainSpan?.let { (start, end) ->
        hostStart + start to hostStart + end
    } ?: (hostStart to hostStart + host.length)
}

private fun SpannableStringBuilder.applyUrlColors(
    @ColorInt urlColor: Int,
    @ColorInt registrableDomainColor: Int,
    registrableDomainOrHostSpan: Pair<Int, Int>,
): SpannableStringBuilder = apply {
    setSpan(
        ForegroundColorSpan(urlColor),
        0,
        length,
        SPAN_INCLUSIVE_INCLUSIVE,
    )

    val (start, end) = registrableDomainOrHostSpan
    setSpan(
        ForegroundColorSpan(registrableDomainColor),
        start,
        end,
        SPAN_INCLUSIVE_INCLUSIVE,
    )
}
+256 −10
Original line number Diff line number Diff line
@@ -5,8 +5,10 @@
package mozilla.components.feature.toolbar.internal

import android.graphics.Color
import android.net.InetAddresses
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.util.Patterns
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.Dispatchers
import mozilla.components.concept.toolbar.Toolbar
@@ -26,8 +28,12 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.verify
import org.robolectric.annotation.Config
import org.robolectric.annotation.Implementation
import org.robolectric.annotation.Implements

@RunWith(AndroidJUnit4::class)
@Config(shadows = [ShadowInetAddresses::class])
class URLRendererTest {

    @get:Rule
@@ -104,10 +110,7 @@ class URLRendererTest {
        }
    }

    private suspend fun testRenderWithColoredUrl(
        testUrl: String,
        expectedRegistrableDomainSpan: Pair<Int, Int>,
    ) {
    private suspend fun getSpannedUrl(testUrl: String): SpannableStringBuilder {
        val configuration = ToolbarFeature.UrlRenderConfiguration(
            publicSuffixList = PublicSuffixList(testContext, Dispatchers.Unconfined),
            registrableDomainColor = Color.RED,
@@ -124,9 +127,14 @@ class URLRendererTest {
        val captor = argumentCaptor<CharSequence>()
        verify(toolbar).url = captor.capture()

        assertNotNull(captor.value)
        assertTrue(captor.value is SpannableStringBuilder)
        val url = captor.value as SpannableStringBuilder
        return requireNotNull(captor.value as? SpannableStringBuilder) { "Toolbar URL should not be null" }
    }

    private suspend fun testRenderWithColoredUrl(
        testUrl: String,
        expectedRegistrableDomainSpan: Pair<Int, Int>,
    ) {
        val url = getSpannedUrl(testUrl)

        assertEquals(testUrl, url.toString())

@@ -143,8 +151,186 @@ class URLRendererTest {
        assertEquals(expectedRegistrableDomainSpan.second, url.getSpanEnd(spans[1]))
    }

    private suspend fun testRenderWithUncoloredUrl(testUrl: String) {
        val url = getSpannedUrl(testUrl)

        assertEquals(testUrl, url.toString())

        val spans = url.getSpans(0, url.length, ForegroundColorSpan::class.java)

        assertEquals(0, spans.size)
    }

    @Test
    fun `GIVEN a simple domain WHEN getting registrable domain span in host THEN span is returned`() {
        runTestOnMain {
            val domainSpan = getRegistrableDomainSpanInHost(
                host = "www.mozilla.org",
                publicSuffixList = PublicSuffixList(testContext, Dispatchers.Unconfined),
            )

            assertEquals(4 to 15, domainSpan)
        }
    }

    @Test
    fun `GIVEN a host with a trailing period in the domain WHEN getting registrable domain span in host THEN span is returned`() {
        runTestOnMain {
            val domainSpan = getRegistrableDomainSpanInHost(
                host = "www.mozilla.org.",
                publicSuffixList = PublicSuffixList(testContext, Dispatchers.Unconfined),
            )

            assertEquals(4 to 15, domainSpan)
        }
    }

    @Test
    fun `GIVEN a host with a repeated domain WHEN getting registrable domain span in host THEN the span of the last occurrence of domain is returned`() {
        runTestOnMain {
            val domainSpan = getRegistrableDomainSpanInHost(
                host = "mozilla.org.mozilla.org",
                publicSuffixList = PublicSuffixList(testContext, Dispatchers.Unconfined),
            )

            assertEquals(12 to 23, domainSpan)
        }
    }

    @Test
    fun `GIVEN an IPv4 address as host WHEN getting registrable domain span in host THEN null is returned`() {
        runTestOnMain {
            val domainSpan = getRegistrableDomainSpanInHost(
                host = "127.0.0.1",
                publicSuffixList = PublicSuffixList(testContext, Dispatchers.Unconfined),
            )

            assertNull(domainSpan)
        }
    }

    @Test
    fun `GIVEN an IPv6 address as host WHEN getting registrable domain span in host THEN null is returned`() {
        runTestOnMain {
            val domainSpan = getRegistrableDomainSpanInHost(
                host = "[::1]",
                publicSuffixList = PublicSuffixList(testContext, Dispatchers.Unconfined),
            )

            assertNull(domainSpan)
        }
    }

    @Test
    fun `GIVEN a non PSL domain as host WHEN getting registrable domain span in host THEN null is returned`() {
        runTestOnMain {
            val domainSpan = getRegistrableDomainSpanInHost(
                host = "localhost",
                publicSuffixList = PublicSuffixList(testContext, Dispatchers.Unconfined),
            )

            assertNull(domainSpan)
        }
    }

    @Test
    fun `GIVEN a simple URL WHEN getting registrable domain or host span THEN span is returned`() {
        runTestOnMain {
            val span = getRegistrableDomainOrHostSpan(
                url = "https://www.mozilla.org/",
                publicSuffixList = PublicSuffixList(testContext, Dispatchers.Unconfined),
            )

            assertEquals(12 to 23, span)
        }
    }

    @Test
    fun `GIVEN a URL with a trailing period in the domain WHEN getting registrable domain or host span THEN span is returned`() {
        runTestOnMain {
            val span = getRegistrableDomainOrHostSpan(
                url = "https://www.mozilla.org./",
                publicSuffixList = PublicSuffixList(testContext, Dispatchers.Unconfined),
            )

            assertEquals(12 to 23, span)
        }
    }

    @Test
    fun `Render with simple URL`() {
    fun `GIVEN a URL with a repeated domain WHEN getting registrable domain or host span THEN the span of the last occurrence of domain is returned`() {
        runTestOnMain {
            val span = getRegistrableDomainOrHostSpan(
                url = "https://mozilla.org.mozilla.org/",
                publicSuffixList = PublicSuffixList(testContext, Dispatchers.Unconfined),
            )

            assertEquals(20 to 31, span)
        }
    }

    @Test
    fun `GIVEN a URL with an IPv4 address WHEN getting registrable domain or host span THEN the span of the IP part is returned`() {
        runTestOnMain {
            val span = getRegistrableDomainOrHostSpan(
                url = "http://127.0.0.1/",
                publicSuffixList = PublicSuffixList(testContext, Dispatchers.Unconfined),
            )

            assertEquals(7 to 16, span)
        }
    }

    @Test
    fun `GIVEN a URL with an IPv6 address WHEN getting registrable domain or host span THEN the span of the IP part is returned`() {
        runTestOnMain {
            val span = getRegistrableDomainOrHostSpan(
                url = "http://[::1]/",
                publicSuffixList = PublicSuffixList(testContext, Dispatchers.Unconfined),
            )

            assertEquals(7 to 12, span)
        }
    }

    @Test
    fun `GIVEN a URL with a non PSL domain WHEN getting registrable domain or host span THEN the span of the host part is returned`() {
        runTestOnMain {
            val span = getRegistrableDomainOrHostSpan(
                url = "http://localhost/",
                publicSuffixList = PublicSuffixList(testContext, Dispatchers.Unconfined),
            )

            assertEquals(7 to 16, span)
        }
    }

    @Test
    fun `GIVEN an internal page name WHEN getting registrable domain or host span THEN null is returned`() {
        runTestOnMain {
            val span = getRegistrableDomainOrHostSpan(
                url = "about:mozilla",
                publicSuffixList = PublicSuffixList(testContext, Dispatchers.Unconfined),
            )

            assertNull(span)
        }
    }

    @Test
    fun `GIVEN a content URI WHEN getting registrable domain or host span THEN null is returned`() {
        runTestOnMain {
            val span = getRegistrableDomainOrHostSpan(
                url = "content://media/external/file/1000000000",
                publicSuffixList = PublicSuffixList(testContext, Dispatchers.Unconfined),
            )

            assertNull(span)
        }
    }

    @Test
    fun `GIVEN a simple URL WHEN rendering it THEN registrable domain is colored`() {
        runTestOnMain {
            testRenderWithColoredUrl(
                testUrl = "https://www.mozilla.org/",
@@ -154,7 +340,7 @@ class URLRendererTest {
    }

    @Test
    fun `Render with URL containing domain with trailing period`() {
    fun `GIVEN a URL with a trailing period in the domain WHEN rendering it THEN registrable domain is colored`() {
        runTestOnMain {
            testRenderWithColoredUrl(
                testUrl = "https://www.mozilla.org./",
@@ -164,7 +350,7 @@ class URLRendererTest {
    }

    @Test
    fun `Render with URL containing repeated domain`() {
    fun `GIVEN a URL with a repeated domain WHEN rendering it THEN the last occurrence of domain is colored`() {
        runTestOnMain {
            testRenderWithColoredUrl(
                testUrl = "https://mozilla.org.mozilla.org/",
@@ -172,4 +358,64 @@ class URLRendererTest {
            )
        }
    }

    @Test
    fun `GIVEN a URL with an IPv4 address WHEN rendering it THEN the IP part is colored`() {
        runTestOnMain {
            testRenderWithColoredUrl(
                testUrl = "http://127.0.0.1/",
                expectedRegistrableDomainSpan = 7 to 16,
            )
        }
    }

    @Test
    fun `GIVEN a URL with an IPv6 address WHEN rendering it THEN the IP part is colored`() {
        runTestOnMain {
            testRenderWithColoredUrl(
                testUrl = "http://[::1]/",
                expectedRegistrableDomainSpan = 7 to 12,
            )
        }
    }

    @Test
    fun `GIVEN a URL with a non PSL domain WHEN rendering it THEN host colored`() {
        runTestOnMain {
            testRenderWithColoredUrl(
                testUrl = "http://localhost/",
                expectedRegistrableDomainSpan = 7 to 16,
            )
        }
    }

    @Test
    fun `GIVEN an internal page name WHEN rendering it THEN nothing is colored`() {
        runTestOnMain {
            testRenderWithUncoloredUrl("about:mozilla")
        }
    }

    @Test
    fun `GIVEN a content URI WHEN rendering it THEN nothing is colored`() {
        runTestOnMain {
            testRenderWithUncoloredUrl("content://media/external/file/1000000000")
        }
    }
}

/**
 * Robolectric default implementation of [InetAddresses] returns false for any address.
 * This shadow is used to override that behavior and return true for any IP address.
 */
@Implements(InetAddresses::class)
class ShadowInetAddresses {
    companion object {
        @Implementation
        @JvmStatic
        @Suppress("DEPRECATION")
        fun isNumericAddress(address: String): Boolean {
            return Patterns.IP_ADDRESS.matcher(address).matches() || address.contains(":")
        }
    }
}