Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[connect] Improve WebView state handling #10268

Merged
merged 25 commits into from
Feb 26, 2025
Merged

Conversation

lng-stripe
Copy link
Contributor

@lng-stripe lng-stripe commented Feb 26, 2025

Summary

Sorry for the big PR. It's a big refactoring that took quite a bit of design-by-doing to get right.

tl;dr:

  • Issue: WebViews are reloading on configuration changes.
  • Fix: Cache the WebView in a ViewModel for re-use across configuration changes. Allow integrators to provide a cache key to support multiple instances of a component in the same activity/screen.

Quoting my kdocs for StripeConnectWebViewContainerViewModel:

/**
 * ViewModel for [StripeConnectWebViewContainer]. Importantly, it also provides a cached WebView instance.
 * Creating and caching a View in a ViewModel is generally bad practice, but it's necessary in our case to provide
 * a good experience for both SDK integrators and end users.
 *
 * The issue is that the typical View state restoration mechanism does essentially nothing for WebViews. Any time
 * a WebView is recreated, it loses its state and reloads the page. This would be a bad UX especially for components
 * with forms.
 *
 * As of this writing, the official guidance is to "avoid activity recreation by specifying the configuration changes
 * handled by your app (rather than by the system)". However, that would likely be infeasible for users wanting to
 * integrate this SDK into their apps.
 *
 * So, the approach here is to:
 *  1. Create the WebView using the application context in the ViewModel.
 *  2. Cache the WebView in the ViewModel, which is retained across configuration changes.
 *  3. Directly handle WebView interactions in the ViewModel itself (as opposed to the WebView's containing view),
 *     otherwise WebView events may be dropped during View recreation.
 *  4. Whenever an activity [Context] is needed, find it within the view tree hierarchy.
 *
 * @see https://developer.android.com/develop/ui/compose/quick-guides/content/manage-webview-state
 */
What Before After
UX WebView is always reloaded on configuration changes. User may lose their work during Onboarding, for example. WebView state is preserved perfectly across any number of configuration changes.
WebView creation Plain WebView created and configured by the container view via StripeConnectWebViewContainerImpl New self-configured StripeConnectWebView is created by StripeConnectWebViewContainerViewModel.
WebView caching None Cached in StripeConnectWebViewContainerViewModel, leveraging platform persistence mechanisms
WebView interaction logic StripeConnectWebViewContainerController, which did not survive configuration changes StripeConnectWebViewContainerViewModel, which stays alive during configuration change to handle WebView events, e.g. loading state changes
Public API N/A Minimal addition of an optional cache key parameter to component creation methods

See inline comments for more detailed implementation changes.

Top research links:

Motivation

Part of https://jira.corp.stripe.com/browse/CAX-3737

Testing

  • Added tests
  • Modified tests
  • Manually verified

Demos

Before
webview-before.mov
After
webview-after.mov
After (smoke testing)
webview-after-regression-test.mov
After (XML layout support)
webview-after-xml.mov

Copy link
Contributor

github-actions bot commented Feb 26, 2025

Diffuse output:

OLD: identity-example-release-base.apk (signature: V1, V2)
NEW: identity-example-release-pr.apk (signature: V1, V2)

          │          compressed          │         uncompressed         
          ├───────────┬───────────┬──────┼───────────┬───────────┬──────
 APK      │ old       │ new       │ diff │ old       │ new       │ diff 
──────────┼───────────┼───────────┼──────┼───────────┼───────────┼──────
      dex │     2 MiB │     2 MiB │  0 B │   4.1 MiB │   4.1 MiB │  0 B 
     arsc │     1 MiB │     1 MiB │  0 B │     1 MiB │     1 MiB │  0 B 
 manifest │   2.3 KiB │   2.3 KiB │  0 B │     8 KiB │     8 KiB │  0 B 
      res │ 302.6 KiB │ 302.6 KiB │  0 B │ 456.7 KiB │ 456.7 KiB │  0 B 
   native │   6.2 MiB │   6.2 MiB │  0 B │  15.8 MiB │  15.8 MiB │  0 B 
    asset │   7.3 KiB │   7.3 KiB │  0 B │   7.1 KiB │   7.1 KiB │  0 B 
    other │  95.1 KiB │  95.1 KiB │ -1 B │ 182.2 KiB │ 182.2 KiB │  0 B 
──────────┼───────────┼───────────┼──────┼───────────┼───────────┼──────
    total │   9.6 MiB │   9.6 MiB │ -1 B │  21.6 MiB │  21.6 MiB │  0 B 

 DEX     │ old   │ new   │ diff      
─────────┼───────┼───────┼───────────
   files │     1 │     1 │ 0         
 strings │ 20114 │ 20114 │ 0 (+0 -0) 
   types │  6221 │  6221 │ 0 (+0 -0) 
 classes │  5015 │  5015 │ 0 (+0 -0) 
 methods │ 29998 │ 29998 │ 0 (+0 -0) 
  fields │ 17347 │ 17347 │ 0 (+0 -0) 

 ARSC    │ old  │ new  │ diff 
─────────┼──────┼──────┼──────
 configs │  164 │  164 │  0   
 entries │ 3643 │ 3643 │  0
APK
   compressed    │   uncompressed   │                                           
──────────┬──────┼───────────┬──────┤                                           
 size     │ diff │ size      │ diff │ path                                      
──────────┼──────┼───────────┼──────┼───────────────────────────────────────────
  1.2 KiB │ -2 B │   1.2 KiB │  0 B │ ∆ META-INF/CERT.RSA                       
    272 B │ +1 B │     120 B │  0 B │ ∆ META-INF/version-control-info.textproto 
 28.9 KiB │ -1 B │    64 KiB │  0 B │ ∆ META-INF/CERT.SF                        
 25.6 KiB │ +1 B │  63.9 KiB │  0 B │ ∆ META-INF/MANIFEST.MF                    
──────────┼──────┼───────────┼──────┼───────────────────────────────────────────
   56 KiB │ -1 B │ 129.2 KiB │  0 B │ (total)

@lng-stripe lng-stripe force-pushed the lng/connect-fix-webview-state branch from 3faf73b to 563468d Compare February 26, 2025 04:34
@@ -284,14 +284,6 @@ public final class com/stripe/android/connect/appearance/fonts/CustomFontSource$
public final fun serializer ()Lkotlinx/serialization/KSerializer;
}

public final class com/stripe/android/connect/databinding/StripeConnectWebviewBinding : androidx/viewbinding/ViewBinding {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 This XML layout was used by StripeConnectWebViewContainer, but now it constructs the view programmatically now that it's getting the WebView from the ViewModel.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know if this was part included in the API review? If so, I think you need to amend the API review and have someone re-approve it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was only used internally and was not meant to be exposed in the public API.

internal object AccountOnboardingListenerDelegate : ComponentListenerDelegate<AccountOnboardingListener> {
override fun AccountOnboardingListener.delegate(message: SetterFunctionCalledMessage) {
internal object AccountOnboardingListenerDelegate : ComponentListenerDelegate<AccountOnboardingListener>() {
override fun delegate(listener: AccountOnboardingListener, message: SetterFunctionCalledMessage) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 Minor refactoring to be less fancy, more approachable.

"You must call EmbeddedComponentManager.onActivityCreate in your Activity.onCreate function"
}

checkContextDuringCreate(context)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 Extracted helper function.

Comment on lines +160 to +161
internal suspend fun requestCameraPermission(activity: Activity): Boolean? {
if (checkSelfPermission(activity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 Note the change from Context to Activity here and in other places, since using application context would be a runtime error in many cases.

@OptIn(PrivateBetaConnectSDK::class)
internal fun interface ComponentListenerDelegate<Listener : StripeEmbeddedComponentListener> {
fun Listener.delegate(message: SetterFunctionCalledMessage)
internal open class ComponentListenerDelegate<Listener : StripeEmbeddedComponentListener> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 Refactored to be an open class in order to move handling of common component events here. Before, the delegation to the listener was done in the Controller, which is no longer feasible. The Controller has been replaced by a ViewModel, which may not keep a reference to the listener otherwise we risk leaking the Activity during a config change.

val eventFlow: Flow<ComponentEvent> get() = _eventFlow.asSharedFlow()

@VisibleForTesting
internal val delegate = StripeConnectWebViewDelegate()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 I extracted WebView -> VM interactions to this inner class for better code organization and encapsulation, e.g. the Container view should not be able to call onPageStarted on the VM.

Comment on lines +110 to +114
@SuppressLint("StaticFieldLeak") // Should be safe because we're using application.
val webView: StripeConnectWebView =
createWebView(
application = application,
delegate = delegate,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀

// don't send errors for requests that aren't for the main page load
if (isMainPageLoad) {
// TODO - wrap error better
_eventFlow.tryEmit(ComponentEvent.LoadError(RuntimeException(errorString)))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 Emitting an event object instead of directly invoking the Listener before.


val BASE_DEPENDENCIES_KEY = object : CreationExtras.Key<BaseDependencies> {}

val Factory: ViewModelProvider.Factory = viewModelFactory {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 Extra scrutiny here, please.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are you concerned about here? Anything in particular I should be looking out for?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relates to my other comment:

👀 I'd appreciate extra scrutiny here since these ViewModel APIs are new to me. Relevant docs: https://developer.android.com/topic/libraries/architecture/viewmodel/viewmodel-factories

import org.robolectric.RuntimeEnvironment

@RunWith(RobolectricTestRunner::class)
class StripeConnectWebViewTest {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 Tests in here aren't comprehensive, but they weren't before either. I added more to largely sanity check testability.

@lng-stripe lng-stripe marked this pull request as ready for review February 26, 2025 16:54
@lng-stripe lng-stripe requested a review from a team as a code owner February 26, 2025 16:54
@@ -284,14 +284,6 @@ public final class com/stripe/android/connect/appearance/fonts/CustomFontSource$
public final fun serializer ()Lkotlinx/serialization/KSerializer;
}

public final class com/stripe/android/connect/databinding/StripeConnectWebviewBinding : androidx/viewbinding/ViewBinding {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know if this was part included in the API review? If so, I think you need to amend the API review and have someone re-approve it

@@ -31,11 +32,14 @@ class AccountOnboardingView private constructor(
embeddedComponentManager: EmbeddedComponentManager? = null,
listener: AccountOnboardingListener? = null,
props: AccountOnboardingProps? = null,
cacheKey: String? = null,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be included in an updated API review?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. This change will be bundled in an upcoming API review that will also include presenting Account Onboarding full screen as opposed to returning a View.


val BASE_DEPENDENCIES_KEY = object : CreationExtras.Key<BaseDependencies> {}

val Factory: ViewModelProvider.Factory = viewModelFactory {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are you concerned about here? Anything in particular I should be looking out for?

@lng-stripe lng-stripe merged commit 3cc46f7 into master Feb 26, 2025
13 checks passed
@lng-stripe lng-stripe deleted the lng/connect-fix-webview-state branch February 26, 2025 21:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants