diff --git a/connect/src/main/java/com/stripe/android/connect/util/ColorUtils.kt b/connect/src/main/java/com/stripe/android/connect/util/ColorUtils.kt new file mode 100644 index 00000000000..5c0eea7fdd1 --- /dev/null +++ b/connect/src/main/java/com/stripe/android/connect/util/ColorUtils.kt @@ -0,0 +1,42 @@ +package com.stripe.android.connect.util + +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.core.graphics.ColorUtils +import kotlin.math.max +import kotlin.math.min + +/** + * Returns a color for minimum contrast with a given background color + * + * Reference: [WCAG 2.1 Contrast Minimum](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html#dfn-contrast-ratio) + * + * @param color The background color with which to get minimum contrast + * @param minimumRatio The minimum contrast ratio (defaults to WCAG minimum ratio of 4.5) + * @return The adjusted color that meets the minimum contrast ratio + */ +@Suppress("MagicNumber", "ComplexCondition") +@ColorInt +internal fun getContrastingColor(@ColorInt color: Int, minimumRatio: Float = 4.5f): Int { + var adjustedColor = color + + val shouldLighten = ColorUtils.calculateLuminance(color) < 0.5 + val hsv = FloatArray(3) + Color.colorToHSV(adjustedColor, hsv) + + while ( + ColorUtils.calculateContrast(adjustedColor, color) < minimumRatio && + ((shouldLighten && hsv[2] < 1f) || (!shouldLighten && hsv[2] > 0f)) + ) { + if (shouldLighten) { + hsv[2] = min(1f, hsv[2] + HSV_VALUE_STEP_SIZE) + } else { + hsv[2] = max(0f, hsv[2] - HSV_VALUE_STEP_SIZE) + } + adjustedColor = Color.HSVToColor(hsv) + } + + return adjustedColor +} + +private const val HSV_VALUE_STEP_SIZE = 0.1f diff --git a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainer.kt b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainer.kt index d23d8b98fa4..340f7ccea2b 100644 --- a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainer.kt +++ b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainer.kt @@ -1,6 +1,7 @@ package com.stripe.android.connect.webview import android.annotation.SuppressLint +import android.content.res.ColorStateList import android.graphics.Bitmap import android.view.LayoutInflater import android.webkit.JavascriptInterface @@ -175,7 +176,13 @@ internal class StripeConnectWebViewContainerImpl( } private fun bindViewState(state: StripeConnectWebViewContainerState) { - viewBinding?.stripeWebViewProgressBar?.isVisible = state.isNativeLoadingIndicatorVisible + val viewBinding = this.viewBinding ?: return + viewBinding.stripeWebView.setBackgroundColor(state.backgroundColor) + viewBinding.stripeWebViewProgressBar.isVisible = state.isNativeLoadingIndicatorVisible + if (state.isNativeLoadingIndicatorVisible) { + viewBinding.stripeWebViewProgressBar.indeterminateTintList = + ColorStateList.valueOf(state.nativeLoadingIndicatorColor) + } } @VisibleForTesting diff --git a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerController.kt b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerController.kt index fd3798f3847..a6987197621 100644 --- a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerController.kt +++ b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerController.kt @@ -83,6 +83,7 @@ internal class StripeConnectWebViewContainerController( // Bind appearance changes in the manager to the WebView (only when page is loaded). embeddedComponentManager.appearanceFlow .collectLatest { appearance -> + updateState { copy(appearance = appearance) } if (stateFlow.value.receivedPageDidLoad) { view.updateConnectInstance(appearance) } diff --git a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerState.kt b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerState.kt index 1a0afa4543f..052897530aa 100644 --- a/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerState.kt +++ b/connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerState.kt @@ -1,5 +1,12 @@ package com.stripe.android.connect.webview +import android.graphics.Color +import androidx.annotation.ColorInt +import com.stripe.android.connect.PrivateBetaConnectSDK +import com.stripe.android.connect.appearance.Appearance +import com.stripe.android.connect.util.getContrastingColor + +@OptIn(PrivateBetaConnectSDK::class) internal data class StripeConnectWebViewContainerState( /** * True if we received the 'pageDidLoad' message. @@ -15,4 +22,23 @@ internal data class StripeConnectWebViewContainerState( * True if the native loading indicator should be visible. */ val isNativeLoadingIndicatorVisible: Boolean = false, -) + + /** + * The appearance to use for the view. + */ + val appearance: Appearance? = null +) { + /** + * The background color of the view. + */ + @ColorInt + val backgroundColor: Int = + appearance?.colors?.background ?: Color.WHITE + + /** + * The color of the native loading indicator. + */ + @ColorInt + val nativeLoadingIndicatorColor: Int = + appearance?.colors?.secondaryText ?: getContrastingColor(backgroundColor, 4.5f) +} diff --git a/connect/src/test/java/com/stripe/android/connect/util/GetContrastingColorTest.kt b/connect/src/test/java/com/stripe/android/connect/util/GetContrastingColorTest.kt new file mode 100644 index 00000000000..5e773fe1c61 --- /dev/null +++ b/connect/src/test/java/com/stripe/android/connect/util/GetContrastingColorTest.kt @@ -0,0 +1,38 @@ +package com.stripe.android.connect.util + +import android.graphics.Color +import androidx.core.graphics.ColorUtils +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GetContrastingColorTest { + @Test + fun `should not loop infinitely`() { + val midGray = Color.argb(1f, .5f, .5f, .5f) + + // The maximum contrast ratio to mid-gray is 5.28, ensure that this + // returns a color with the maximum contrast ratio that can be achieved + val color = getContrastingColor(midGray, 5.5f) + assertThat(ColorUtils.calculateContrast(color, midGray)).isLessThan(5.5) + assertThat(color).isEqualTo(Color.WHITE) + } + + @Test + fun `should return a contrasting color`() { + listOf( + Color.WHITE, + Color.LTGRAY, + Color.CYAN, + Color.DKGRAY, + Color.BLACK, + ).forEach { bgColor -> + val color = getContrastingColor(bgColor, 4.5f) + val contrast = ColorUtils.calculateContrast(color, bgColor) + assertThat(contrast).isGreaterThan(4.5) + assertThat(contrast).isLessThan(6.0) + } + } +} diff --git a/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewClientTest.kt b/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewClientTest.kt index 3c82eb2c7e1..e924f6f3c32 100644 --- a/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewClientTest.kt +++ b/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewClientTest.kt @@ -13,14 +13,17 @@ import com.stripe.android.core.version.StripeSdkVersion import kotlinx.serialization.json.Json import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner @OptIn(PrivateBetaConnectSDK::class) +@RunWith(RobolectricTestRunner::class) class StripeConnectWebViewClientTest { private val mockContext: Context = mock() diff --git a/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerControllerTest.kt b/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerControllerTest.kt index b8ef1559cd6..3b5aae7bfce 100644 --- a/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerControllerTest.kt +++ b/connect/src/test/java/com/stripe/android/connect/webview/StripeConnectWebViewContainerControllerTest.kt @@ -1,18 +1,27 @@ package com.stripe.android.connect.webview import android.content.Context +import android.graphics.Color import android.net.Uri import android.webkit.WebResourceRequest +import androidx.lifecycle.testing.TestLifecycleOwner +import com.google.common.truth.Truth.assertThat import com.stripe.android.connect.EmbeddedComponentManager import com.stripe.android.connect.EmbeddedComponentManager.Configuration import com.stripe.android.connect.PrivateBetaConnectSDK import com.stripe.android.connect.StripeEmbeddedComponent +import com.stripe.android.connect.appearance.Appearance +import com.stripe.android.connect.appearance.Colors import com.stripe.android.core.Logger +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.doReturn +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.robolectric.RobolectricTestRunner import kotlin.test.assertFalse @@ -31,6 +40,7 @@ class StripeConnectWebViewContainerControllerTest { private val mockStripeIntentLauncher: StripeIntentLauncher = mock() private val mockLogger: Logger = mock() + private val lifecycleOwner = TestLifecycleOwner() private lateinit var controller: StripeConnectWebViewContainerController @Before @@ -99,4 +109,39 @@ class StripeConnectWebViewContainerControllerTest { verify(mockStripeIntentLauncher).launchUrlWithSystemHandler(mockContext, uri) assertTrue(result) } + + fun `should bind to appearance changes`() = runTest { + assertThat(controller.stateFlow.value.appearance).isNull() + + controller.onCreate(lifecycleOwner) + val newAppearance = Appearance() + embeddedComponentManager.update(newAppearance) + + assertThat(controller.stateFlow.value.appearance).isEqualTo(newAppearance) + } + + @Test + fun `view should update appearance`() = runTest { + val appearances = listOf(Appearance(), Appearance(colors = Colors(primary = Color.CYAN))) + controller.onCreate(lifecycleOwner) + + // Shouldn't update appearance until pageDidLoad is received. + verify(view, never()).updateConnectInstance(any()) + + embeddedComponentManager.update(appearances[0]) + controller.onViewAttached() + controller.onPageStarted() + verify(view, never()).updateConnectInstance(any()) + + // Should update appearance when pageDidLoad is received. + controller.onReceivedPageDidLoad() + + // Should update again when appearance changes. + embeddedComponentManager.update(appearances[1]) + + inOrder(view) { + verify(view).updateConnectInstance(appearances[0]) + verify(view).updateConnectInstance(appearances[1]) + } + } }