From 2edfbfb88d4b0a748747065b7b6910bc4dfd8441 Mon Sep 17 00:00:00 2001 From: lng-stripe <91862945+lng-stripe@users.noreply.github.com> Date: Tue, 3 Dec 2024 13:16:22 -0500 Subject: [PATCH] [connect] Add component event listeners (#9690) * refactor `onSetterFunctionCalled` message handling * add component listening and events * tests and ergonomics * show toast on error for demo --- .../ui/appearance/AppearanceViewModel.kt | 2 +- .../payouts/PayoutsExampleActivity.kt | 13 +- connect/api/connect.api | 28 ++++ .../android/connect/AccountOnboardingView.kt | 31 ++++- .../connect/EmbeddedComponentManager.kt | 22 +++- .../com/stripe/android/connect/PayoutsView.kt | 12 +- .../StripeEmbeddedComponentListener.kt | 27 ++++ .../webview/StripeConnectWebViewContainer.kt | 69 +++++----- ...StripeConnectWebViewContainerController.kt | 37 ++++-- .../webview/serialization/ConnectJson.kt | 9 ++ .../SetterFunctionCalledMessage.kt | 124 ++++++++++++++++++ .../webview/StripeConnectWebViewClientTest.kt | 8 +- ...peConnectWebViewContainerControllerTest.kt | 48 ++++++- .../SetterFunctionCalledMessageTest.kt | 42 ++++++ 14 files changed, 413 insertions(+), 59 deletions(-) create mode 100644 connect/src/main/java/com/stripe/android/connect/StripeEmbeddedComponentListener.kt create mode 100644 connect/src/main/java/com/stripe/android/connect/webview/serialization/ConnectJson.kt create mode 100644 connect/src/main/java/com/stripe/android/connect/webview/serialization/SetterFunctionCalledMessage.kt create mode 100644 connect/src/test/java/com/stripe/android/connect/webview/serialization/SetterFunctionCalledMessageTest.kt diff --git a/connect-example/src/main/java/com/stripe/android/connect/example/ui/appearance/AppearanceViewModel.kt b/connect-example/src/main/java/com/stripe/android/connect/example/ui/appearance/AppearanceViewModel.kt index 904964455b7..f5165526eba 100644 --- a/connect-example/src/main/java/com/stripe/android/connect/example/ui/appearance/AppearanceViewModel.kt +++ b/connect-example/src/main/java/com/stripe/android/connect/example/ui/appearance/AppearanceViewModel.kt @@ -20,7 +20,7 @@ class AppearanceViewModel @Inject constructor( ) : ViewModel() { private val logger: Logger = Logger.getInstance(enableLogging = BuildConfig.DEBUG) - private val loggingTag = this::class.java.name + private val loggingTag = this::class.java.simpleName private val _state = MutableStateFlow(AppearanceState()) val state = _state.asStateFlow() diff --git a/connect-example/src/main/java/com/stripe/android/connect/example/ui/features/payouts/PayoutsExampleActivity.kt b/connect-example/src/main/java/com/stripe/android/connect/example/ui/features/payouts/PayoutsExampleActivity.kt index eaf99f2effc..8a2c55cc91d 100644 --- a/connect-example/src/main/java/com/stripe/android/connect/example/ui/features/payouts/PayoutsExampleActivity.kt +++ b/connect-example/src/main/java/com/stripe/android/connect/example/ui/features/payouts/PayoutsExampleActivity.kt @@ -2,6 +2,8 @@ package com.stripe.android.connect.example.ui.features.payouts import android.content.Context import android.view.View +import android.widget.Toast +import com.stripe.android.connect.PayoutsListener import com.stripe.android.connect.PrivateBetaConnectSDK import com.stripe.android.connect.example.R import com.stripe.android.connect.example.ui.common.BasicComponentExampleActivity @@ -13,6 +15,15 @@ class PayoutsExampleActivity : BasicComponentExampleActivity() { override val titleRes: Int = R.string.payouts override fun createComponentView(context: Context): View { - return embeddedComponentManager.createPayoutsView(context) + return embeddedComponentManager.createPayoutsView( + context = context, + listener = Listener(), + ) + } + + private inner class Listener : PayoutsListener { + override fun onLoadError(error: Throwable) { + Toast.makeText(this@PayoutsExampleActivity, error.message, Toast.LENGTH_LONG).show() + } } } diff --git a/connect/api/connect.api b/connect/api/connect.api index e62fd8221ef..15ded05063c 100644 --- a/connect/api/connect.api +++ b/connect/api/connect.api @@ -1,3 +1,13 @@ +public abstract interface class com/stripe/android/connect/AccountOnboardingListener : com/stripe/android/connect/StripeEmbeddedComponentListener { + public abstract fun onExit ()V +} + +public final class com/stripe/android/connect/AccountOnboardingListener$DefaultImpls { + public static fun onExit (Lcom/stripe/android/connect/AccountOnboardingListener;)V + public static fun onLoadError (Lcom/stripe/android/connect/AccountOnboardingListener;Ljava/lang/Throwable;)V + public static fun onLoaderStart (Lcom/stripe/android/connect/AccountOnboardingListener;)V +} + public final class com/stripe/android/connect/BuildConfig { public static final field BUILD_TYPE Ljava/lang/String; public static final field DEBUG Z @@ -13,6 +23,24 @@ public final class com/stripe/android/connect/EmbeddedComponentManager$Configura public synthetic fun newArray (I)[Ljava/lang/Object; } +public abstract interface class com/stripe/android/connect/PayoutsListener : com/stripe/android/connect/StripeEmbeddedComponentListener { +} + +public final class com/stripe/android/connect/PayoutsListener$DefaultImpls { + public static fun onLoadError (Lcom/stripe/android/connect/PayoutsListener;Ljava/lang/Throwable;)V + public static fun onLoaderStart (Lcom/stripe/android/connect/PayoutsListener;)V +} + +public abstract interface class com/stripe/android/connect/StripeEmbeddedComponentListener { + public abstract fun onLoadError (Ljava/lang/Throwable;)V + public abstract fun onLoaderStart ()V +} + +public final class com/stripe/android/connect/StripeEmbeddedComponentListener$DefaultImpls { + public static fun onLoadError (Lcom/stripe/android/connect/StripeEmbeddedComponentListener;Ljava/lang/Throwable;)V + public static fun onLoaderStart (Lcom/stripe/android/connect/StripeEmbeddedComponentListener;)V +} + public final class com/stripe/android/connect/appearance/Appearance : android/os/Parcelable { public static final field $stable I public static final field CREATOR Landroid/os/Parcelable$Creator; diff --git a/connect/src/main/java/com/stripe/android/connect/AccountOnboardingView.kt b/connect/src/main/java/com/stripe/android/connect/AccountOnboardingView.kt index 3923ba9369b..ed3aa1e775e 100644 --- a/connect/src/main/java/com/stripe/android/connect/AccountOnboardingView.kt +++ b/connect/src/main/java/com/stripe/android/connect/AccountOnboardingView.kt @@ -6,6 +6,8 @@ import android.widget.FrameLayout import androidx.annotation.RestrictTo import com.stripe.android.connect.webview.StripeConnectWebViewContainer import com.stripe.android.connect.webview.StripeConnectWebViewContainerImpl +import com.stripe.android.connect.webview.serialization.SetOnExit +import com.stripe.android.connect.webview.serialization.SetterFunctionCalledMessage @PrivateBetaConnectSDK @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @@ -13,9 +15,9 @@ class AccountOnboardingView private constructor( context: Context, attrs: AttributeSet?, defStyleAttr: Int, - webViewContainerBehavior: StripeConnectWebViewContainerImpl, + webViewContainerBehavior: StripeConnectWebViewContainerImpl, ) : FrameLayout(context, attrs, defStyleAttr), - StripeConnectWebViewContainer by webViewContainerBehavior { + StripeConnectWebViewContainer by webViewContainerBehavior { @JvmOverloads constructor( @@ -23,13 +25,16 @@ class AccountOnboardingView private constructor( attrs: AttributeSet? = null, defStyleAttr: Int = 0, embeddedComponentManager: EmbeddedComponentManager? = null, + listener: AccountOnboardingListener? = null, ) : this( context, attrs, defStyleAttr, StripeConnectWebViewContainerImpl( embeddedComponent = StripeEmbeddedComponent.ACCOUNT_ONBOARDING, - embeddedComponentManager = embeddedComponentManager + embeddedComponentManager = embeddedComponentManager, + listener = listener, + listenerDelegate = AccountOnboardingListenerDelegate ) ) @@ -37,3 +42,23 @@ class AccountOnboardingView private constructor( webViewContainerBehavior.initializeView(this) } } + +@PrivateBetaConnectSDK +interface AccountOnboardingListener : StripeEmbeddedComponentListener { + /** + * The connected account has exited the onboarding process. + */ + fun onExit() {} +} + +@OptIn(PrivateBetaConnectSDK::class) +internal object AccountOnboardingListenerDelegate : ComponentListenerDelegate { + override fun AccountOnboardingListener.delegate(message: SetterFunctionCalledMessage) { + when (message.value) { + is SetOnExit -> onExit() + else -> { + // Ignore. + } + } + } +} diff --git a/connect/src/main/java/com/stripe/android/connect/EmbeddedComponentManager.kt b/connect/src/main/java/com/stripe/android/connect/EmbeddedComponentManager.kt index 3ad9b4e3866..84b1bd5f214 100644 --- a/connect/src/main/java/com/stripe/android/connect/EmbeddedComponentManager.kt +++ b/connect/src/main/java/com/stripe/android/connect/EmbeddedComponentManager.kt @@ -28,15 +28,29 @@ class EmbeddedComponentManager( /** * Create a new [AccountOnboardingView] for inclusion in the view hierarchy. */ - fun createAccountOnboardingView(context: Context): AccountOnboardingView { - return AccountOnboardingView(context = context, embeddedComponentManager = this) + fun createAccountOnboardingView( + context: Context, + listener: AccountOnboardingListener? = null + ): AccountOnboardingView { + return AccountOnboardingView( + context = context, + embeddedComponentManager = this, + listener = listener + ) } /** * Create a new [PayoutsView] for inclusion in the view hierarchy. */ - fun createPayoutsView(context: Context): PayoutsView { - return PayoutsView(context = context, embeddedComponentManager = this) + fun createPayoutsView( + context: Context, + listener: PayoutsListener? = null + ): PayoutsView { + return PayoutsView( + context = context, + embeddedComponentManager = this, + listener = listener + ) } internal fun getInitialParams(context: Context): ConnectInstanceJs { diff --git a/connect/src/main/java/com/stripe/android/connect/PayoutsView.kt b/connect/src/main/java/com/stripe/android/connect/PayoutsView.kt index 5bbdfeda541..c6409c858f9 100644 --- a/connect/src/main/java/com/stripe/android/connect/PayoutsView.kt +++ b/connect/src/main/java/com/stripe/android/connect/PayoutsView.kt @@ -13,9 +13,9 @@ class PayoutsView private constructor( context: Context, attrs: AttributeSet?, defStyleAttr: Int, - webViewContainerBehavior: StripeConnectWebViewContainerImpl, + webViewContainerBehavior: StripeConnectWebViewContainerImpl, ) : FrameLayout(context, attrs, defStyleAttr), - StripeConnectWebViewContainer by webViewContainerBehavior { + StripeConnectWebViewContainer by webViewContainerBehavior { @JvmOverloads constructor( @@ -23,13 +23,16 @@ class PayoutsView private constructor( attrs: AttributeSet? = null, defStyleAttr: Int = 0, embeddedComponentManager: EmbeddedComponentManager? = null, + listener: PayoutsListener? = null, ) : this( context, attrs, defStyleAttr, StripeConnectWebViewContainerImpl( embeddedComponent = StripeEmbeddedComponent.PAYOUTS, - embeddedComponentManager = embeddedComponentManager + embeddedComponentManager = embeddedComponentManager, + listener = listener, + listenerDelegate = ComponentListenerDelegate.ignore(), ) ) @@ -37,3 +40,6 @@ class PayoutsView private constructor( webViewContainerBehavior.initializeView(this) } } + +@PrivateBetaConnectSDK +interface PayoutsListener : StripeEmbeddedComponentListener diff --git a/connect/src/main/java/com/stripe/android/connect/StripeEmbeddedComponentListener.kt b/connect/src/main/java/com/stripe/android/connect/StripeEmbeddedComponentListener.kt new file mode 100644 index 00000000000..fe2c87beb56 --- /dev/null +++ b/connect/src/main/java/com/stripe/android/connect/StripeEmbeddedComponentListener.kt @@ -0,0 +1,27 @@ +package com.stripe.android.connect + +import com.stripe.android.connect.webview.serialization.SetterFunctionCalledMessage + +@PrivateBetaConnectSDK +interface StripeEmbeddedComponentListener { + /** + * The component executes this callback function before any UI is displayed to the user. + */ + fun onLoaderStart() {} + + /** + * The component executes this callback function when a load failure occurs. + */ + fun onLoadError(error: Throwable) {} +} + +@OptIn(PrivateBetaConnectSDK::class) +internal fun interface ComponentListenerDelegate { + fun Listener.delegate(message: SetterFunctionCalledMessage) + + companion object { + internal fun ignore(): ComponentListenerDelegate { + return ComponentListenerDelegate { _ -> } + } + } +} 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 340f7ccea2b..34c2e92695f 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 @@ -18,16 +18,19 @@ import androidx.core.view.isVisible import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.lifecycleScope import com.stripe.android.connect.BuildConfig +import com.stripe.android.connect.ComponentListenerDelegate import com.stripe.android.connect.EmbeddedComponentManager import com.stripe.android.connect.PrivateBetaConnectSDK import com.stripe.android.connect.StripeEmbeddedComponent +import com.stripe.android.connect.StripeEmbeddedComponentListener import com.stripe.android.connect.appearance.Appearance import com.stripe.android.connect.databinding.StripeConnectWebviewBinding import com.stripe.android.connect.webview.serialization.AccountSessionClaimedMessage import com.stripe.android.connect.webview.serialization.ConnectInstanceJs +import com.stripe.android.connect.webview.serialization.ConnectJson import com.stripe.android.connect.webview.serialization.PageLoadMessage import com.stripe.android.connect.webview.serialization.SecureWebViewMessage -import com.stripe.android.connect.webview.serialization.SetterMessage +import com.stripe.android.connect.webview.serialization.SetterFunctionCalledMessage import com.stripe.android.connect.webview.serialization.toJs import com.stripe.android.core.Logger import com.stripe.android.core.version.StripeSdkVersion @@ -35,20 +38,22 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonObject @PrivateBetaConnectSDK @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -interface StripeConnectWebViewContainer { +interface StripeConnectWebViewContainer { /** - * Set the [EmbeddedComponentManager] to use for this view. + * Initializes the [EmbeddedComponentManager] and listener to use for this view. * Must be called when this view is created via XML. * Cannot be called more than once per instance. */ - fun setEmbeddedComponentManager(embeddedComponentManager: EmbeddedComponentManager) + fun initialize( + embeddedComponentManager: EmbeddedComponentManager, + listener: Listener?, + ) } @OptIn(PrivateBetaConnectSDK::class) @@ -65,16 +70,13 @@ internal interface StripeConnectWebViewContainerInternal { } @OptIn(PrivateBetaConnectSDK::class) -internal class StripeConnectWebViewContainerImpl( +internal class StripeConnectWebViewContainerImpl( val embeddedComponent: StripeEmbeddedComponent, embeddedComponentManager: EmbeddedComponentManager?, + listener: Listener?, + private val listenerDelegate: ComponentListenerDelegate, private val logger: Logger = Logger.getInstance(enableLogging = BuildConfig.DEBUG), - private val jsonSerializer: Json = - Json { - ignoreUnknownKeys = true - explicitNulls = false - } -) : StripeConnectWebViewContainer, +) : StripeConnectWebViewContainer, StripeConnectWebViewContainerInternal { private var viewBinding: StripeConnectWebviewBinding? = null @@ -86,15 +88,15 @@ internal class StripeConnectWebViewContainerImpl( @VisibleForTesting internal val stripeWebChromeClient = StripeWebChromeClient() - private var controller: StripeConnectWebViewContainerController? = null + private var controller: StripeConnectWebViewContainerController? = null init { if (embeddedComponentManager != null) { - initializeController(embeddedComponentManager) + initialize(embeddedComponentManager, listener) } } - fun initializeView(view: FrameLayout) { + internal fun initializeView(view: FrameLayout) { val viewBinding = StripeConnectWebviewBinding.inflate( LayoutInflater.from(view.context), view @@ -104,10 +106,6 @@ internal class StripeConnectWebViewContainerImpl( bindViewToController() } - override fun setEmbeddedComponentManager(embeddedComponentManager: EmbeddedComponentManager) { - initializeController(embeddedComponentManager) - } - @VisibleForTesting internal fun initializeWebView(webView: WebView) { with(webView) { @@ -130,14 +128,19 @@ internal class StripeConnectWebViewContainerImpl( } } - private fun initializeController(embeddedComponentManager: EmbeddedComponentManager) { - if (controller != null) { + override fun initialize( + embeddedComponentManager: EmbeddedComponentManager, + listener: Listener? + ) { + if (this.controller != null) { throw IllegalStateException("EmbeddedComponentManager is already set") } - controller = StripeConnectWebViewContainerController( + this.controller = StripeConnectWebViewContainerController( view = this, embeddedComponentManager = embeddedComponentManager, embeddedComponent = embeddedComponent, + listener = listener, + listenerDelegate = listenerDelegate, ) bindViewToController() } @@ -167,11 +170,12 @@ internal class StripeConnectWebViewContainerImpl( ConnectInstanceJs(appearance = appearance.toJs()) webView?.evaluateSdkJs( "updateConnectInstance", - jsonSerializer.encodeToJsonElement(payload).jsonObject + ConnectJson.encodeToJsonElement(payload).jsonObject ) } override fun loadUrl(url: String) { + webView?.clearCache(true) webView?.loadUrl(url) } @@ -264,31 +268,26 @@ internal class StripeConnectWebViewContainerImpl( val context = checkNotNull(webView?.context) val initialParams = checkNotNull(controller?.getInitialParams(context)) logger.debug("InitParams fetched: ${initialParams.toDebugString()}") - return jsonSerializer.encodeToString(initialParams) + return ConnectJson.encodeToString(initialParams) } @JavascriptInterface fun onSetterFunctionCalled(message: String) { - val setterMessage = jsonSerializer.decodeFromString(message) - logger.debug("Setter function called: $setterMessage") + val parsed = ConnectJson.decodeFromString(message) + logger.debug("Setter function called: $parsed") - when (setterMessage.setter) { - // Emitted when connect js has initialized and the component renders a loading state - "setOnLoaderStart" -> { - controller?.onReceivedSetOnLoaderStart() - } - } + controller?.onReceivedSetterFunctionCalled(parsed) } @JavascriptInterface fun openSecureWebView(message: String) { - val secureWebViewData = jsonSerializer.decodeFromString(message) + val secureWebViewData = ConnectJson.decodeFromString(message) logger.debug("Open secure web view with data: $secureWebViewData") } @JavascriptInterface fun pageDidLoad(message: String) { - val pageLoadMessage = jsonSerializer.decodeFromString(message) + val pageLoadMessage = ConnectJson.decodeFromString(message) logger.debug("Page did load: $pageLoadMessage") controller?.onReceivedPageDidLoad() @@ -296,7 +295,7 @@ internal class StripeConnectWebViewContainerImpl( @JavascriptInterface fun accountSessionClaimed(message: String) { - val accountSessionClaimedMessage = jsonSerializer.decodeFromString(message) + val accountSessionClaimedMessage = ConnectJson.decodeFromString(message) logger.debug("Account session claimed: $accountSessionClaimedMessage") } 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 a6987197621..19142aac839 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 @@ -9,10 +9,15 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.stripe.android.connect.BuildConfig +import com.stripe.android.connect.ComponentListenerDelegate import com.stripe.android.connect.EmbeddedComponentManager import com.stripe.android.connect.PrivateBetaConnectSDK import com.stripe.android.connect.StripeEmbeddedComponent +import com.stripe.android.connect.StripeEmbeddedComponentListener import com.stripe.android.connect.webview.serialization.ConnectInstanceJs +import com.stripe.android.connect.webview.serialization.SetOnLoadError +import com.stripe.android.connect.webview.serialization.SetOnLoaderStart +import com.stripe.android.connect.webview.serialization.SetterFunctionCalledMessage import com.stripe.android.core.Logger import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -22,10 +27,12 @@ import kotlinx.coroutines.launch @OptIn(PrivateBetaConnectSDK::class) @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -internal class StripeConnectWebViewContainerController( +internal class StripeConnectWebViewContainerController( private val view: StripeConnectWebViewContainerInternal, private val embeddedComponentManager: EmbeddedComponentManager, private val embeddedComponent: StripeEmbeddedComponent, + private val listener: Listener?, + private val listenerDelegate: ComponentListenerDelegate, private val stripeIntentLauncher: StripeIntentLauncher = StripeIntentLauncherImpl(), private val logger: Logger = Logger.getInstance(enableLogging = BuildConfig.DEBUG), ) : DefaultLifecycleObserver { @@ -113,14 +120,28 @@ internal class StripeConnectWebViewContainerController( } /** - * Callback to invoke upon receiving 'setOnLoaderStart' message. + * Callback to invoke upon receiving 'onSetterFunctionCalled' message. */ - fun onReceivedSetOnLoaderStart() { - updateState { - copy( - receivedSetOnLoaderStart = true, - isNativeLoadingIndicatorVisible = false, - ) + fun onReceivedSetterFunctionCalled(message: SetterFunctionCalledMessage) { + when (val value = message.value) { + is SetOnLoaderStart -> { + updateState { + copy( + receivedSetOnLoaderStart = true, + isNativeLoadingIndicatorVisible = false, + ) + } + listener?.onLoaderStart() + } + is SetOnLoadError -> { + // TODO - wrap error better + listener?.onLoadError(RuntimeException("${value.type}: ${value.message}")) + } + else -> { + with(listenerDelegate) { + listener?.delegate(message) + } + } } } diff --git a/connect/src/main/java/com/stripe/android/connect/webview/serialization/ConnectJson.kt b/connect/src/main/java/com/stripe/android/connect/webview/serialization/ConnectJson.kt new file mode 100644 index 00000000000..2fbc7eb7c52 --- /dev/null +++ b/connect/src/main/java/com/stripe/android/connect/webview/serialization/ConnectJson.kt @@ -0,0 +1,9 @@ +package com.stripe.android.connect.webview.serialization + +import kotlinx.serialization.json.Json + +internal val ConnectJson: Json = + Json { + ignoreUnknownKeys = true + explicitNulls = false + } diff --git a/connect/src/main/java/com/stripe/android/connect/webview/serialization/SetterFunctionCalledMessage.kt b/connect/src/main/java/com/stripe/android/connect/webview/serialization/SetterFunctionCalledMessage.kt new file mode 100644 index 00000000000..befae7cefa6 --- /dev/null +++ b/connect/src/main/java/com/stripe/android/connect/webview/serialization/SetterFunctionCalledMessage.kt @@ -0,0 +1,124 @@ +package com.stripe.android.connect.webview.serialization + +import com.stripe.android.connect.BuildConfig +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.serializer + +@Serializable(with = SetterFunctionCalledMessageSerializer::class) +internal data class SetterFunctionCalledMessage( + val setter: String, + val value: Value +) { + constructor(value: Value) : this( + setter = value.expectedSetterName, + value = value + ) + + init { + require(!BuildConfig.DEBUG || value is UnknownValue || setter == value.expectedSetterName) { + "Setter does not match value type: setter=$setter, value=$value" + } + } + + sealed interface Value + + @Serializable + data class UnknownValue( + val value: JsonElement + ) : Value + + companion object { + val Value.expectedSetterName: String + get() { + require(this !is UnknownValue) + return javaClass.simpleName.replaceFirstChar { it.lowercaseChar() } + } + } +} + +// Values + +/** + * Emitted when Connect JS has initialized and the component renders a loading state. + */ +@Serializable +internal data class SetOnLoaderStart( + val elementTagName: String +) : SetterFunctionCalledMessage.Value + +/** + * The component executes this callback function when a load failure occurs. + */ +@Serializable +internal data class SetOnLoadError( + val type: String, // TODO - possibly use an enum or sealed class here. + val message: String?, +) : SetterFunctionCalledMessage.Value + +/** + * The connected account has exited the onboarding process. + */ +@Serializable +internal data object SetOnExit : SetterFunctionCalledMessage.Value + +// Serialization + +internal object SetterFunctionCalledMessageSerializer : KSerializer { + override val descriptor: SerialDescriptor = JsonObject.serializer().descriptor + + override fun deserialize(decoder: Decoder): SetterFunctionCalledMessage { + check(decoder is JsonDecoder) + val jsonObject = decoder.decodeJsonElement().jsonObject + val setter = jsonObject.getValue("setter").jsonPrimitive.content + val valueJson = jsonObject.getValue("value") + val valueSerializer = decoder.json.valueSerializerForSetter(setter) + val value = valueSerializer + ?.let { decoder.json.decodeFromJsonElement(it, valueJson) } + ?: SetterFunctionCalledMessage.UnknownValue( + value = valueJson + ) + return SetterFunctionCalledMessage(setter = setter, value = value) + } + + override fun serialize(encoder: Encoder, value: SetterFunctionCalledMessage) { + check(encoder is JsonEncoder) + val resultValue = + if (value.value is SetterFunctionCalledMessage.UnknownValue) { + value.value.value // lol + } else { + val valueSerializer = encoder.json.valueSerializerForSetter(value.setter)!! + encoder.json.encodeToJsonElement(valueSerializer, value.value) + } + encoder.encodeJsonElement( + buildJsonObject { + put("setter", JsonPrimitive(value.setter)) + put("value", resultValue) + } + ) + } + + private fun Json.valueSerializerForSetter(setter: String): KSerializer? { + val className = buildString { + append(SetterFunctionCalledMessage::class.java.`package`!!.name) + append(".") + append(setter.replaceFirstChar { it.uppercaseChar() }) + } + return runCatching { + @Suppress("UNCHECKED_CAST") + serializersModule.serializer(Class.forName(className)) as KSerializer + }.getOrNull() + } +} 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 e924f6f3c32..c996b2b6db4 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 @@ -4,13 +4,14 @@ import android.content.Context import android.webkit.ValueCallback import android.webkit.WebSettings import android.webkit.WebView +import com.stripe.android.connect.ComponentListenerDelegate import com.stripe.android.connect.EmbeddedComponentManager import com.stripe.android.connect.EmbeddedComponentManager.Configuration +import com.stripe.android.connect.PayoutsListener import com.stripe.android.connect.PrivateBetaConnectSDK import com.stripe.android.connect.StripeEmbeddedComponent import com.stripe.android.core.Logger 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 @@ -35,7 +36,7 @@ class StripeConnectWebViewClientTest { on { context } doReturn mockContext } - private lateinit var container: StripeConnectWebViewContainerImpl + private lateinit var container: StripeConnectWebViewContainerImpl private val webViewClient get() = container.stripeWebViewClient @Before @@ -55,8 +56,9 @@ class StripeConnectWebViewClientTest { container = StripeConnectWebViewContainerImpl( embeddedComponent = StripeEmbeddedComponent.PAYOUTS, embeddedComponentManager = embeddedComponentManager, + listener = null, logger = Logger.getInstance(enableLogging = false), - jsonSerializer = Json { ignoreUnknownKeys = true }, + listenerDelegate = ComponentListenerDelegate.ignore() ) } 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 3b5aae7bfce..762dd3d0a19 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 @@ -6,14 +6,20 @@ 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.ComponentListenerDelegate import com.stripe.android.connect.EmbeddedComponentManager import com.stripe.android.connect.EmbeddedComponentManager.Configuration +import com.stripe.android.connect.PayoutsListener 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.connect.webview.serialization.SetOnLoadError +import com.stripe.android.connect.webview.serialization.SetOnLoaderStart +import com.stripe.android.connect.webview.serialization.SetterFunctionCalledMessage import com.stripe.android.core.Logger import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonNull import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -37,11 +43,17 @@ class StripeConnectWebViewContainerControllerTest { fetchClientSecretCallback = { }, ) private val embeddedComponent: StripeEmbeddedComponent = StripeEmbeddedComponent.PAYOUTS + + private val delegateReceivedEvents = mutableListOf() + private val listener: PayoutsListener = mock() + private val listenerDelegate: ComponentListenerDelegate = + ComponentListenerDelegate { delegateReceivedEvents.add(it) } + private val mockStripeIntentLauncher: StripeIntentLauncher = mock() private val mockLogger: Logger = mock() private val lifecycleOwner = TestLifecycleOwner() - private lateinit var controller: StripeConnectWebViewContainerController + private lateinit var controller: StripeConnectWebViewContainerController @Before fun setup() { @@ -49,6 +61,8 @@ class StripeConnectWebViewContainerControllerTest { view = view, embeddedComponentManager = embeddedComponentManager, embeddedComponent = embeddedComponent, + listener = listener, + listenerDelegate = listenerDelegate, stripeIntentLauncher = mockStripeIntentLauncher, logger = mockLogger, ) @@ -110,6 +124,7 @@ class StripeConnectWebViewContainerControllerTest { assertTrue(result) } + @Test fun `should bind to appearance changes`() = runTest { assertThat(controller.stateFlow.value.appearance).isNull() @@ -120,6 +135,37 @@ class StripeConnectWebViewContainerControllerTest { assertThat(controller.stateFlow.value.appearance).isEqualTo(newAppearance) } + @Test + fun `should handle SetOnLoaderStart`() = runTest { + val message = SetterFunctionCalledMessage(SetOnLoaderStart("")) + controller.onPageStarted() + controller.onReceivedSetterFunctionCalled(message) + + val state = controller.stateFlow.value + assertThat(state.receivedSetOnLoaderStart).isTrue() + assertThat(state.isNativeLoadingIndicatorVisible).isFalse() + verify(listener).onLoaderStart() + } + + @Test + fun `should handle SetOnLoadError`() = runTest { + val message = SetterFunctionCalledMessage(SetOnLoadError("", null)) + controller.onReceivedSetterFunctionCalled(message) + + verify(listener).onLoadError(any()) + } + + @Test + fun `should handle other messages`() = runTest { + val message = SetterFunctionCalledMessage( + setter = "foo", + value = SetterFunctionCalledMessage.UnknownValue(JsonNull) + ) + controller.onReceivedSetterFunctionCalled(message) + + assertThat(delegateReceivedEvents).contains(message) + } + @Test fun `view should update appearance`() = runTest { val appearances = listOf(Appearance(), Appearance(colors = Colors(primary = Color.CYAN))) diff --git a/connect/src/test/java/com/stripe/android/connect/webview/serialization/SetterFunctionCalledMessageTest.kt b/connect/src/test/java/com/stripe/android/connect/webview/serialization/SetterFunctionCalledMessageTest.kt new file mode 100644 index 00000000000..d2853aaf62e --- /dev/null +++ b/connect/src/test/java/com/stripe/android/connect/webview/serialization/SetterFunctionCalledMessageTest.kt @@ -0,0 +1,42 @@ +package com.stripe.android.connect.webview.serialization + +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Test + +class SetterFunctionCalledMessageTest { + @Test(expected = IllegalArgumentException::class) + fun `should error if setter and value class names don't match`() { + SetterFunctionCalledMessage( + setter = "foo", + value = SetOnLoaderStart(elementTagName = "") + ) + } + + @Test + fun `should not validate naming for unknown values`() { + SetterFunctionCalledMessage( + setter = "foo", + value = SetterFunctionCalledMessage.UnknownValue(JsonPrimitive("")) + ) + } + + @Test + fun `should serialize and deserialize correctly`() { + listOf( + SetterFunctionCalledMessage(SetOnLoaderStart(elementTagName = "foo")) to + """{"setter":"setOnLoaderStart","value":{"elementTagName":"foo"}}""", + SetterFunctionCalledMessage(SetOnExit) to + """{"setter":"setOnExit","value":{}}""", + SetterFunctionCalledMessage( + setter = "foo", + value = SetterFunctionCalledMessage.UnknownValue(value = JsonPrimitive("bar")) + ) to """{"setter":"foo","value":"bar"}""", + ).forEach { (obj, expectedJson) -> + val json = ConnectJson.encodeToString(obj) + assertThat(json).isEqualTo(expectedJson) + assertThat(ConnectJson.decodeFromString(json)).isEqualTo(obj) + } + } +}