diff --git a/Stripe.xcworkspace/contents.xcworkspacedata b/Stripe.xcworkspace/contents.xcworkspacedata index 4bff4165bf6..cc15335c594 100644 --- a/Stripe.xcworkspace/contents.xcworkspacedata +++ b/Stripe.xcworkspace/contents.xcworkspacedata @@ -78,7 +78,4 @@ location = "group:IntegrationTester/IntegrationTester.xcodeproj"> - - diff --git a/StripeConnect/StripeConnect.xcodeproj/project.pbxproj b/StripeConnect/StripeConnect.xcodeproj/project.pbxproj index 991b7c50227..c7999acd124 100644 --- a/StripeConnect/StripeConnect.xcodeproj/project.pbxproj +++ b/StripeConnect/StripeConnect.xcodeproj/project.pbxproj @@ -39,7 +39,6 @@ 41542A692C88B6F2004E728E /* JSONEncoder+extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41542A682C88B6F2004E728E /* JSONEncoder+extension.swift */; }; 41542A6B2C88B79E004E728E /* JSONSerialization+extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41542A6A2C88B79E004E728E /* JSONSerialization+extension.swift */; }; 4161C2732C9D0A8A005BD67C /* AccountOnboardingViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4161C2722C9D0A8A005BD67C /* AccountOnboardingViewControllerTests.swift */; }; - 4161C2752C9DB1B9005BD67C /* StripeUICore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4161C2742C9DB1B9005BD67C /* StripeUICore.framework */; }; 4161C2792C9DB1CE005BD67C /* StripeUICore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4161C2782C9DB1CE005BD67C /* StripeUICore.framework */; }; 4161C27E2C9DB566005BD67C /* AccountCollectionOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4161C27D2C9DB566005BD67C /* AccountCollectionOptions.swift */; }; 4161C28C2CA1B54E005BD67C /* OnSetterFunctionCalledMessageHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4161C28B2CA1B54E005BD67C /* OnSetterFunctionCalledMessageHandlerTests.swift */; }; @@ -95,6 +94,27 @@ E65691222CA52D5900E0DB00 /* StripeConnect+Exports.swift in Sources */ = {isa = PBXBuildFile; fileRef = E65691212CA52D5900E0DB00 /* StripeConnect+Exports.swift */; }; E65691252CA52F9D00E0DB00 /* NotificationBannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E65691232CA52F8600E0DB00 /* NotificationBannerViewController.swift */; }; E65691272CA533CD00E0DB00 /* OnNotificationsChangeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E65691262CA533CD00E0DB00 /* OnNotificationsChangeHandler.swift */; }; + E6660D8F2CDC418C002A7631 /* ConnectAnalyticEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660D8E2CDC418C002A7631 /* ConnectAnalyticEvent.swift */; }; + E6660D902CDC418C002A7631 /* ComponentAnalyticsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660D8D2CDC418C002A7631 /* ComponentAnalyticsClient.swift */; }; + E6660D912CDC418C002A7631 /* AnalyticsClientV2+Connect.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660D8C2CDC418C002A7631 /* AnalyticsClientV2+Connect.swift */; }; + E6660D9A2CDC4194002A7631 /* PageLoadErrorEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660D972CDC4194002A7631 /* PageLoadErrorEvent.swift */; }; + E6660D9B2CDC4194002A7631 /* ComponentViewedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660D942CDC4194002A7631 /* ComponentViewedEvent.swift */; }; + E6660D9C2CDC4194002A7631 /* ComponentCreatedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660D922CDC4194002A7631 /* ComponentCreatedEvent.swift */; }; + E6660D9D2CDC4194002A7631 /* DeserializeMessageErrorEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660D962CDC4194002A7631 /* DeserializeMessageErrorEvent.swift */; }; + E6660D9E2CDC4194002A7631 /* UnexpectedLoadErrorTypeEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660D982CDC4194002A7631 /* UnexpectedLoadErrorTypeEvent.swift */; }; + E6660D9F2CDC4194002A7631 /* UnrecognizedSetterEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660D992CDC4194002A7631 /* UnrecognizedSetterEvent.swift */; }; + E6660DA02CDC4194002A7631 /* ComponentLoadedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660D932CDC4194002A7631 /* ComponentLoadedEvent.swift */; }; + E6660DA12CDC4194002A7631 /* ComponentWebPageLoadedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660D952CDC4194002A7631 /* ComponentWebPageLoadedEvent.swift */; }; + E6660DA42CDC4209002A7631 /* URL+extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660DA32CDC4209002A7631 /* URL+extension.swift */; }; + E6660DA52CDC4209002A7631 /* Error+extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660DA22CDC4209002A7631 /* Error+extensions.swift */; }; + E6660DA72CDC42F4002A7631 /* Encodable+Connect.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660DA62CDC42F4002A7631 /* Encodable+Connect.swift */; }; + E6660DA92CDC563C002A7631 /* AuthenticatedWebViewOpenedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660DA82CDC563C002A7631 /* AuthenticatedWebViewOpenedEvent.swift */; }; + E6660DAB2CDC5702002A7631 /* AuthenticatedWebViewRedirectedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660DAA2CDC5702002A7631 /* AuthenticatedWebViewRedirectedEvent.swift */; }; + E6660DAD2CDC5745002A7631 /* AuthenticatedWebViewCanceledEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660DAC2CDC5745002A7631 /* AuthenticatedWebViewCanceledEvent.swift */; }; + E6660DAF2CDC576F002A7631 /* AuthenticatedWebViewErrorEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660DAE2CDC576F002A7631 /* AuthenticatedWebViewErrorEvent.swift */; }; + E6660DB12CDD8E25002A7631 /* ComponentAnalyticsClient+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660DB02CDD8E1B002A7631 /* ComponentAnalyticsClient+Mock.swift */; }; + E6660DB32CDD8F6E002A7631 /* StripeCoreTestUtils.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E6660DB22CDD8F6E002A7631 /* StripeCoreTestUtils.framework */; }; + E6660DB72CDD99EF002A7631 /* MockComponentAnalyticsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6660DB62CDD99E7002A7631 /* MockComponentAnalyticsClient.swift */; }; E688AE002CADD8C400951D97 /* NotificationBannerViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E688ADFF2CADD8C400951D97 /* NotificationBannerViewControllerTests.swift */; }; E688AE032CADE36C00951D97 /* OnNotificationsChangeHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E688AE022CADE36900951D97 /* OnNotificationsChangeHandlerTests.swift */; }; E6C5F5F62C9FEE0200861709 /* AccountManagementViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6C5F5F52C9FEE0200861709 /* AccountManagementViewController.swift */; }; @@ -207,6 +227,27 @@ E65691212CA52D5900E0DB00 /* StripeConnect+Exports.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StripeConnect+Exports.swift"; sourceTree = ""; }; E65691232CA52F8600E0DB00 /* NotificationBannerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationBannerViewController.swift; sourceTree = ""; }; E65691262CA533CD00E0DB00 /* OnNotificationsChangeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnNotificationsChangeHandler.swift; sourceTree = ""; }; + E6660D8C2CDC418C002A7631 /* AnalyticsClientV2+Connect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnalyticsClientV2+Connect.swift"; sourceTree = ""; }; + E6660D8D2CDC418C002A7631 /* ComponentAnalyticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComponentAnalyticsClient.swift; sourceTree = ""; }; + E6660D8E2CDC418C002A7631 /* ConnectAnalyticEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectAnalyticEvent.swift; sourceTree = ""; }; + E6660D922CDC4194002A7631 /* ComponentCreatedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComponentCreatedEvent.swift; sourceTree = ""; }; + E6660D932CDC4194002A7631 /* ComponentLoadedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComponentLoadedEvent.swift; sourceTree = ""; }; + E6660D942CDC4194002A7631 /* ComponentViewedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComponentViewedEvent.swift; sourceTree = ""; }; + E6660D952CDC4194002A7631 /* ComponentWebPageLoadedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComponentWebPageLoadedEvent.swift; sourceTree = ""; }; + E6660D962CDC4194002A7631 /* DeserializeMessageErrorEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeserializeMessageErrorEvent.swift; sourceTree = ""; }; + E6660D972CDC4194002A7631 /* PageLoadErrorEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageLoadErrorEvent.swift; sourceTree = ""; }; + E6660D982CDC4194002A7631 /* UnexpectedLoadErrorTypeEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnexpectedLoadErrorTypeEvent.swift; sourceTree = ""; }; + E6660D992CDC4194002A7631 /* UnrecognizedSetterEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnrecognizedSetterEvent.swift; sourceTree = ""; }; + E6660DA22CDC4209002A7631 /* Error+extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Error+extensions.swift"; sourceTree = ""; }; + E6660DA32CDC4209002A7631 /* URL+extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+extension.swift"; sourceTree = ""; }; + E6660DA62CDC42F4002A7631 /* Encodable+Connect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Encodable+Connect.swift"; sourceTree = ""; }; + E6660DA82CDC563C002A7631 /* AuthenticatedWebViewOpenedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedWebViewOpenedEvent.swift; sourceTree = ""; }; + E6660DAA2CDC5702002A7631 /* AuthenticatedWebViewRedirectedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedWebViewRedirectedEvent.swift; sourceTree = ""; }; + E6660DAC2CDC5745002A7631 /* AuthenticatedWebViewCanceledEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedWebViewCanceledEvent.swift; sourceTree = ""; }; + E6660DAE2CDC576F002A7631 /* AuthenticatedWebViewErrorEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedWebViewErrorEvent.swift; sourceTree = ""; }; + E6660DB02CDD8E1B002A7631 /* ComponentAnalyticsClient+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComponentAnalyticsClient+Mock.swift"; sourceTree = ""; }; + E6660DB22CDD8F6E002A7631 /* StripeCoreTestUtils.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = StripeCoreTestUtils.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E6660DB62CDD99E7002A7631 /* MockComponentAnalyticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockComponentAnalyticsClient.swift; sourceTree = ""; }; E688ADFF2CADD8C400951D97 /* NotificationBannerViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationBannerViewControllerTests.swift; sourceTree = ""; }; E688AE022CADE36900951D97 /* OnNotificationsChangeHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnNotificationsChangeHandlerTests.swift; sourceTree = ""; }; E6C5F5F52C9FEE0200861709 /* AccountManagementViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountManagementViewController.swift; sourceTree = ""; }; @@ -233,7 +274,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4161C2752C9DB1B9005BD67C /* StripeUICore.framework in Frameworks */, + E6660DB32CDD8F6E002A7631 /* StripeCoreTestUtils.framework in Frameworks */, 41D17A4B2C5A73A7007C6EE6 /* StripeConnect.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -300,6 +341,7 @@ 413987C62C63F34B001D375E /* Internal */ = { isa = PBXGroup; children = ( + E6660D8A2CDC414E002A7631 /* Analytics */, E640C9CD2CBF26C9009D0C6E /* AuthenticatedWebView */, 416E9ED02C77F6C100A0B917 /* Extensions */, 410D0FE22C6D31C6009B0E26 /* StripeConnectConstants.swift */, @@ -363,12 +405,15 @@ 416E9ED02C77F6C100A0B917 /* Extensions */ = { isa = PBXGroup; children = ( - 416E9ED32C77F90600A0B917 /* WKScriptMessage+extension.swift */, + E6660DA62CDC42F4002A7631 /* Encodable+Connect.swift */, + E6660DA22CDC4209002A7631 /* Error+extensions.swift */, + 41054E422C989AAD00383C09 /* Font+Extension.swift */, E6D3C8EF2CBE1455003CE967 /* HTTPURLResponse+StripeConnect.swift */, 41542A682C88B6F2004E728E /* JSONEncoder+extension.swift */, 41542A6A2C88B79E004E728E /* JSONSerialization+extension.swift */, - 41054E422C989AAD00383C09 /* Font+Extension.swift */, E6EF91C62CBA3BE60082DD1B /* UIViewController+StripeConnect.swift */, + E6660DA32CDC4209002A7631 /* URL+extension.swift */, + 416E9ED32C77F90600A0B917 /* WKScriptMessage+extension.swift */, ); path = Extensions; sourceTree = ""; @@ -439,6 +484,7 @@ 41A2A5662C5AC5110077FC74 /* Frameworks */ = { isa = PBXGroup; children = ( + E6660DB22CDD8F6E002A7631 /* StripeCoreTestUtils.framework */, 4161C2782C9DB1CE005BD67C /* StripeUICore.framework */, 4161C2742C9DB1B9005BD67C /* StripeUICore.framework */, 41A2A5672C5AC5120077FC74 /* StripeCore.framework */, @@ -461,6 +507,8 @@ 41BCCFEE2C8B3C7900797E01 /* Helpers */ = { isa = PBXGroup; children = ( + E6660DB62CDD99E7002A7631 /* MockComponentAnalyticsClient.swift */, + E6660DB02CDD8E1B002A7631 /* ComponentAnalyticsClient+Mock.swift */, 41BCCFEF2C8B3C8900797E01 /* AppearanceWrapper+Default.swift */, E6F486082C9E8A40000D914F /* ConnectJSURLParamsTests.swift */, 41BCCFF22C8B449800797E01 /* TestHelpers.swift */, @@ -555,6 +603,36 @@ path = AuthenticatedWebView; sourceTree = ""; }; + E6660D8A2CDC414E002A7631 /* Analytics */ = { + isa = PBXGroup; + children = ( + E6660D8B2CDC4158002A7631 /* Events */, + E6660D8C2CDC418C002A7631 /* AnalyticsClientV2+Connect.swift */, + E6660D8D2CDC418C002A7631 /* ComponentAnalyticsClient.swift */, + E6660D8E2CDC418C002A7631 /* ConnectAnalyticEvent.swift */, + ); + path = Analytics; + sourceTree = ""; + }; + E6660D8B2CDC4158002A7631 /* Events */ = { + isa = PBXGroup; + children = ( + E6660DAC2CDC5745002A7631 /* AuthenticatedWebViewCanceledEvent.swift */, + E6660DAE2CDC576F002A7631 /* AuthenticatedWebViewErrorEvent.swift */, + E6660DA82CDC563C002A7631 /* AuthenticatedWebViewOpenedEvent.swift */, + E6660DAA2CDC5702002A7631 /* AuthenticatedWebViewRedirectedEvent.swift */, + E6660D922CDC4194002A7631 /* ComponentCreatedEvent.swift */, + E6660D932CDC4194002A7631 /* ComponentLoadedEvent.swift */, + E6660D942CDC4194002A7631 /* ComponentViewedEvent.swift */, + E6660D952CDC4194002A7631 /* ComponentWebPageLoadedEvent.swift */, + E6660D962CDC4194002A7631 /* DeserializeMessageErrorEvent.swift */, + E6660D972CDC4194002A7631 /* PageLoadErrorEvent.swift */, + E6660D982CDC4194002A7631 /* UnexpectedLoadErrorTypeEvent.swift */, + E6660D992CDC4194002A7631 /* UnrecognizedSetterEvent.swift */, + ); + path = Events; + sourceTree = ""; + }; E688AE012CADE35300951D97 /* NotificationBanner */ = { isa = PBXGroup; children = ( @@ -717,6 +795,7 @@ E65691222CA52D5900E0DB00 /* StripeConnect+Exports.swift in Sources */, E65691252CA52F9D00E0DB00 /* NotificationBannerViewController.swift in Sources */, 410D0FE32C6D31C6009B0E26 /* StripeConnectConstants.swift in Sources */, + E6660DAD2CDC5745002A7631 /* AuthenticatedWebViewCanceledEvent.swift in Sources */, 4186664A2C66AC66003DB62E /* OnSetterFunctionCalledMessageHandler.swift in Sources */, E6D3C8EE2CBE1404003CE967 /* HTTPStatusError.swift in Sources */, 413987CC2C63F34B001D375E /* VoidPayload.swift in Sources */, @@ -734,17 +813,34 @@ 41810D7A2C8A0AAD00F10EB7 /* CustomFontSource.swift in Sources */, 413987CA2C63F34B001D375E /* ScriptMessageHandler.swift in Sources */, 416E9E862C76B35E00A0B917 /* PayoutsViewController.swift in Sources */, + E6660DA92CDC563C002A7631 /* AuthenticatedWebViewOpenedEvent.swift in Sources */, 41542A6B2C88B79E004E728E /* JSONSerialization+extension.swift in Sources */, E6C5F5F62C9FEE0200861709 /* AccountManagementViewController.swift in Sources */, 413987C82C63F34B001D375E /* DebugMessageHandler.swift in Sources */, 410D0FCC2C6CFFDB009B0E26 /* AccountSessionClaimedMessageHandler.swift in Sources */, + E6660DAF2CDC576F002A7631 /* AuthenticatedWebViewErrorEvent.swift in Sources */, 41BCCFED2C8B34F600797E01 /* StringCodingKey.swift in Sources */, 4186664E2C66ACB3003DB62E /* OnLoadErrorMessageHandler.swift in Sources */, 410D0FE52C6D32F0009B0E26 /* ApplicationURLOpener.swift in Sources */, E6F485F82C9E35A5000D914F /* PaymentDetailsViewController.swift in Sources */, + E6660D8F2CDC418C002A7631 /* ConnectAnalyticEvent.swift in Sources */, + E6660D902CDC418C002A7631 /* ComponentAnalyticsClient.swift in Sources */, + E6660D912CDC418C002A7631 /* AnalyticsClientV2+Connect.swift in Sources */, + E6660DAB2CDC5702002A7631 /* AuthenticatedWebViewRedirectedEvent.swift in Sources */, + E6660D9A2CDC4194002A7631 /* PageLoadErrorEvent.swift in Sources */, + E6660D9B2CDC4194002A7631 /* ComponentViewedEvent.swift in Sources */, + E6660D9C2CDC4194002A7631 /* ComponentCreatedEvent.swift in Sources */, + E6660D9D2CDC4194002A7631 /* DeserializeMessageErrorEvent.swift in Sources */, + E6660D9E2CDC4194002A7631 /* UnexpectedLoadErrorTypeEvent.swift in Sources */, + E6660D9F2CDC4194002A7631 /* UnrecognizedSetterEvent.swift in Sources */, + E6660DA02CDC4194002A7631 /* ComponentLoadedEvent.swift in Sources */, + E6660DA12CDC4194002A7631 /* ComponentWebPageLoadedEvent.swift in Sources */, + E6660DA72CDC42F4002A7631 /* Encodable+Connect.swift in Sources */, 416E9E762C751B0500A0B917 /* EmbeddedComponentManager.swift in Sources */, 416E9ECF2C77EAA400A0B917 /* EmbeddedComponentError.swift in Sources */, E640C9CC2CBF0C1E009D0C6E /* AuthenticatedWebViewManager.swift in Sources */, + E6660DA42CDC4209002A7631 /* URL+extension.swift in Sources */, + E6660DA52CDC4209002A7631 /* Error+extensions.swift in Sources */, 410D0FDF2C6D3176009B0E26 /* ConnectWebViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -757,6 +853,8 @@ 410D0FD92C6D1F25009B0E26 /* UpdateConnectInstanceSenderTests.swift in Sources */, E6F486092C9E8A40000D914F /* ConnectJSURLParamsTests.swift in Sources */, 416E9E892C76B36F00A0B917 /* PayoutsViewControllerTests.swift in Sources */, + E6660DB72CDD99EF002A7631 /* MockComponentAnalyticsClient.swift in Sources */, + E6660DB12CDD8E25002A7631 /* ComponentAnalyticsClient+Mock.swift in Sources */, 41814EEB2C6BCAB30014EB5E /* DebugMessageHandlerTests.swift in Sources */, 41814EEF2C6BEF2C0014EB5E /* FetchInitParamsMessageHandlerTests.swift in Sources */, 41810D692C88C4B100F10EB7 /* AppearanceTests.swift in Sources */, diff --git a/StripeConnect/StripeConnect/Source/Components/AccountManagementViewController.swift b/StripeConnect/StripeConnect/Source/Components/AccountManagementViewController.swift index 010342ec3f0..94bc91c4833 100644 --- a/StripeConnect/StripeConnect/Source/Components/AccountManagementViewController.swift +++ b/StripeConnect/StripeConnect/Source/Components/AccountManagementViewController.swift @@ -28,12 +28,14 @@ public class AccountManagementViewController: UIViewController { init(componentManager: EmbeddedComponentManager, collectionOptions: AccountCollectionOptions, - loadContent: Bool) { + loadContent: Bool, + analyticsClientFactory: ComponentAnalyticsClientFactory) { super.init(nibName: nil, bundle: nil) webVC = ConnectComponentWebViewController( componentManager: componentManager, componentType: .accountManagement, - loadContent: loadContent + loadContent: loadContent, + analyticsClientFactory: analyticsClientFactory ) { Props(collectionOptions: collectionOptions) } didFailLoadWithError: { [weak self] error in diff --git a/StripeConnect/StripeConnect/Source/Components/AccountOnboardingViewController.swift b/StripeConnect/StripeConnect/Source/Components/AccountOnboardingViewController.swift index 8550d1e49eb..ce33531cb43 100644 --- a/StripeConnect/StripeConnect/Source/Components/AccountOnboardingViewController.swift +++ b/StripeConnect/StripeConnect/Source/Components/AccountOnboardingViewController.swift @@ -38,13 +38,15 @@ public class AccountOnboardingViewController: UIViewController { init(props: Props, componentManager: EmbeddedComponentManager, - loadContent: Bool + loadContent: Bool, + analyticsClientFactory: ComponentAnalyticsClientFactory ) { super.init(nibName: nil, bundle: nil) webVC = ConnectComponentWebViewController( componentManager: componentManager, componentType: .onboarding, - loadContent: loadContent + loadContent: loadContent, + analyticsClientFactory: analyticsClientFactory ) { props } didFailLoadWithError: { [weak self] error in diff --git a/StripeConnect/StripeConnect/Source/Components/NotificationBannerViewController.swift b/StripeConnect/StripeConnect/Source/Components/NotificationBannerViewController.swift index 022dfe41d38..20d5bfa807e 100644 --- a/StripeConnect/StripeConnect/Source/Components/NotificationBannerViewController.swift +++ b/StripeConnect/StripeConnect/Source/Components/NotificationBannerViewController.swift @@ -25,12 +25,14 @@ public class NotificationBannerViewController: UIViewController { init(componentManager: EmbeddedComponentManager, collectionOptions: AccountCollectionOptions, - loadContent: Bool) { + loadContent: Bool, + analyticsClientFactory: ComponentAnalyticsClientFactory) { super.init(nibName: nil, bundle: nil) webVC = ConnectComponentWebViewController( componentManager: componentManager, componentType: .notificationBanner, - loadContent: loadContent + loadContent: loadContent, + analyticsClientFactory: analyticsClientFactory ) { Props(collectionOptions: collectionOptions) } didFailLoadWithError: { [weak self] error in diff --git a/StripeConnect/StripeConnect/Source/Components/PaymentDetailsViewController.swift b/StripeConnect/StripeConnect/Source/Components/PaymentDetailsViewController.swift index 2f4271bbc57..0c5953967e2 100644 --- a/StripeConnect/StripeConnect/Source/Components/PaymentDetailsViewController.swift +++ b/StripeConnect/StripeConnect/Source/Components/PaymentDetailsViewController.swift @@ -18,12 +18,14 @@ public class PaymentDetailsViewController: UIViewController { public weak var delegate: PaymentDetailsViewControllerDelegate? init(componentManager: EmbeddedComponentManager, - loadContent: Bool) { + loadContent: Bool, + analyticsClientFactory: ComponentAnalyticsClientFactory) { super.init(nibName: nil, bundle: nil) webVC = ConnectComponentWebViewController( componentManager: componentManager, componentType: .paymentDetails, - loadContent: loadContent + loadContent: loadContent, + analyticsClientFactory: analyticsClientFactory ) { [weak self] error in guard let self else { return } delegate?.paymentDetails(self, didFailLoadWithError: error) diff --git a/StripeConnect/StripeConnect/Source/Components/PayoutsViewController.swift b/StripeConnect/StripeConnect/Source/Components/PayoutsViewController.swift index e42b57986ae..f52eaa1566e 100644 --- a/StripeConnect/StripeConnect/Source/Components/PayoutsViewController.swift +++ b/StripeConnect/StripeConnect/Source/Components/PayoutsViewController.swift @@ -18,12 +18,14 @@ public class PayoutsViewController: UIViewController { public weak var delegate: PayoutsViewControllerDelegate? init(componentManager: EmbeddedComponentManager, - loadContent: Bool) { + loadContent: Bool, + analyticsClientFactory: ComponentAnalyticsClientFactory) { super.init(nibName: nil, bundle: nil) webVC = ConnectComponentWebViewController( componentManager: componentManager, componentType: .payouts, - loadContent: loadContent + loadContent: loadContent, + analyticsClientFactory: analyticsClientFactory ) { [weak self] error in guard let self else { return } delegate?.payouts(self, didFailLoadWithError: error) diff --git a/StripeConnect/StripeConnect/Source/EmbeddedComponentManager.swift b/StripeConnect/StripeConnect/Source/EmbeddedComponentManager.swift index 9638427a64d..f58a73b2cf1 100644 --- a/StripeConnect/StripeConnect/Source/EmbeddedComponentManager.swift +++ b/StripeConnect/StripeConnect/Source/EmbeddedComponentManager.swift @@ -6,7 +6,7 @@ // import JavaScriptCore -import StripeCore +@_spi(STP) import StripeCore import UIKit /// Manages Connect embedded components @@ -28,6 +28,12 @@ public class EmbeddedComponentManager { // content should load. var shouldLoadContent: Bool = true + // This should only be used for tests to mock the analytics logger + var analyticsClientFactory: ComponentAnalyticsClientFactory = { + ComponentAnalyticsClient(client: AnalyticsClientV2.sharedConnect, + commonFields: $0) + } + /** Initializes a StripeConnect instance. @@ -66,7 +72,9 @@ public class EmbeddedComponentManager { /// Creates a payouts component /// - Seealso: https://docs.stripe.com/connect/supported-embedded-components/payouts public func createPayoutsViewController() -> PayoutsViewController { - .init(componentManager: self, loadContent: shouldLoadContent) + .init(componentManager: self, + loadContent: shouldLoadContent, + analyticsClientFactory: analyticsClientFactory) } /** @@ -96,12 +104,15 @@ public class EmbeddedComponentManager { collectionOptions: collectionOptions ), componentManager: self, - loadContent: shouldLoadContent) + loadContent: shouldLoadContent, + analyticsClientFactory: analyticsClientFactory) } @_spi(DashboardOnly) public func createPaymentDetailsViewController() -> PaymentDetailsViewController { - .init(componentManager: self, loadContent: shouldLoadContent) + .init(componentManager: self, + loadContent: shouldLoadContent, + analyticsClientFactory: analyticsClientFactory) } @_spi(DashboardOnly) @@ -110,7 +121,8 @@ public class EmbeddedComponentManager { ) -> AccountManagementViewController { .init(componentManager: self, collectionOptions: collectionOptions, - loadContent: shouldLoadContent) + loadContent: shouldLoadContent, + analyticsClientFactory: analyticsClientFactory) } @_spi(DashboardOnly) @@ -119,7 +131,8 @@ public class EmbeddedComponentManager { ) -> NotificationBannerViewController { .init(componentManager: self, collectionOptions: collectionOptions, - loadContent: shouldLoadContent) + loadContent: shouldLoadContent, + analyticsClientFactory: analyticsClientFactory) } /// Used to keep reference of all web views associated with this component manager. diff --git a/StripeConnect/StripeConnect/Source/Helpers/ConnectJSURLParams.swift b/StripeConnect/StripeConnect/Source/Helpers/ConnectJSURLParams.swift index 20e5777e31e..95f2298da4f 100644 --- a/StripeConnect/StripeConnect/Source/Helpers/ConnectJSURLParams.swift +++ b/StripeConnect/StripeConnect/Source/Helpers/ConnectJSURLParams.swift @@ -53,12 +53,8 @@ extension ConnectJSURLParams { } } - var url: URL { - guard let data = try? JSONEncoder().encode(self), - let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - // TODO: MXMOBILE-2491 Log error - return StripeConnectConstants.connectJSBaseURL - } + func url() throws -> URL { + let dict = try jsonDictionary(with: .connectEncoder) // Append as hash params return URL(string: "#\(URLEncoder.queryString(from: dict))", relativeTo: StripeConnectConstants.connectJSBaseURL)! diff --git a/StripeConnect/StripeConnect/Source/Internal/Analytics/AnalyticsClientV2+Connect.swift b/StripeConnect/StripeConnect/Source/Internal/Analytics/AnalyticsClientV2+Connect.swift new file mode 100644 index 00000000000..3323972d25f --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Analytics/AnalyticsClientV2+Connect.swift @@ -0,0 +1,15 @@ +// +// AnalyticsClientV2+Connect.swift +// StripeConnect +// +// Created by Mel Ludowise on 10/4/24. +// + +@_spi(STP) import StripeCore + +extension AnalyticsClientV2 { + static let sharedConnect = AnalyticsClientV2( + clientId: "mobile_connect_sdk", + origin: "stripe-connect-ios" + ) +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Analytics/ComponentAnalyticsClient.swift b/StripeConnect/StripeConnect/Source/Internal/Analytics/ComponentAnalyticsClient.swift new file mode 100644 index 00000000000..e5658efcb39 --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Analytics/ComponentAnalyticsClient.swift @@ -0,0 +1,267 @@ +// +// ComponentAnalyticsClient.swift +// StripeConnect +// +// Created by Mel Ludowise on 10/1/24. +// + +@_spi(STP) import StripeCore + +typealias ComponentAnalyticsClientFactory = (ComponentAnalyticsClient.CommonFields) -> ComponentAnalyticsClient + +/// Wraps `AnalyticsClientV2` with Connect-specific analytic properties. +/// An analytics client instance should only be used in one component instance +/// as it tracks component-specific loading metrics. +@dynamicMemberLookup +class ComponentAnalyticsClient { + /// Fields common to all Connect analytic events + struct CommonFields: Encodable { + /// The platform's publishable key + /// - Note: This will be null when the account is a dashboard account as user keys should never be logged to analytics + private(set) var publishableKey: String? + + /// ID of the platform account + /// - Note: This is expected to be null unless originating from a dashboard account, + /// otherwise the backend derives this value from the `publishableKey` + let platformId: String? + + /// ID of the connected account returned by the `AccountSessionClaimedMessageHandler` + var merchantId: String? + + /// Represents if the account is in live mode + /// - Note: This is expected to be null unless originating from a dashboard account, + /// otherwise the backend derives this value from the `publishableKey` + let livemode: Bool? + + /// The type of component this analytic is originating from + let component: ComponentType + + /// A UUID representing a specific component instance. + /// All events related to this instance will have the same UUID + let componentInstance: UUID + } + + let client: AnalyticsClientV2Protocol + + private(set) var commonFields: CommonFields + + /// The `pageViewID` returned from the `PageDidLoadMessageHandler` + var pageViewId: String? + + /// Time the page began to load + var loadStart: Date? + + /// Time the component was first viewed + var componentFirstViewedTime: Date? + + /// If `ComponentWebPageLoadedEvent` was already logged + private(set) var loggedPageLoaded = false + + /// If `ComponentLoadedEvent` was already logged + private(set) var loggedComponentLoaded = false + + init(client: AnalyticsClientV2Protocol, + commonFields: CommonFields) { + self.client = client + self.commonFields = commonFields + } + + // Makes for easy access to common fields + subscript(dynamicMember keyPath: WritableKeyPath) -> T { + get { commonFields[keyPath: keyPath] } + set { commonFields[keyPath: keyPath] = newValue } + } + + func log(event: Event) { + do { + var dict = try commonFields.jsonDictionary(with: .analyticsEncoder) + let metadataDict = try event.metadata.jsonDictionary(with: .analyticsEncoder) + dict["event_metadata"] = metadataDict + + // Also log metadata fields as first-level fields to make it easier + // to configure alerting + dict.mergeAssertingOnOverwrites(metadataDict) + + client.log(eventName: event.name, parameters: dict) + } catch { + // We were unable to encode the analytic parameters + logClientError(error) + } + } + + /// The component is viewed on screen (`viewDidAppear` lifecycle event) + /// - Parameter viewedAt: Time the user viewed the component + func logComponentViewed(viewedAt: Date) { + componentFirstViewedTime = viewedAt + log(event: ComponentViewedEvent()) + } + + /// The web page finished loading (`didFinish navigation` event) + /// - Parameter loadEnd: Time the web page finished loading + func logComponentWebPageLoaded(loadEnd: Date) { + guard !loggedPageLoaded, let loadStart else { + return + } + + log(event: ComponentWebPageLoadedEvent(metadata: .init( + timeToLoad: loadEnd.timeIntervalSince(loadStart) + ))) + + // Prevent the analytic from being logged again in the even the page is reloaded. + // This can happen if the app is backgrounded for a long period then foregrounded. + loggedPageLoaded = true + } + + /// The component is successfully loaded within the web view. + /// Triggered from `componentDidLoad` message handler from the web view. + /// - Parameter loadEnd: Time the component finished loading + func logComponentLoaded(loadEnd: Date) { + guard !loggedComponentLoaded, let loadStart else { + return + } + + log(event: ComponentLoadedEvent(metadata: .init( + pageViewId: pageViewId, + timeToLoad: loadEnd.timeIntervalSince(loadStart), + perceivedTimeToLoad: componentFirstViewedTime.map(loadEnd.timeIntervalSince) ?? 0 + ))) + + // Prevent the analytic from being logged again in the even the page is reloaded. + // This can happen if the app is backgrounded for a long period then foregrounded. + loggedComponentLoaded = true + } + + /// The web view sends an onLoadError that can’t be deserialized by the SDK. + /// - Parameter type: The error `type` property from web + func logUnexpectedLoadErrorType(type: String) { + log(event: UnexpectedLoadErrorTypeEvent(metadata: .init( + errorType: type, + pageViewId: pageViewId + ))) + } + + /// If the web view calls `onSetterFunctionCalled` with a `setter` argument the SDK doesn’t know how to handle. + /// - Parameter setter: The `setter` property sent from web + func logUnexpectedSetterEvent(setter: String) { + log(event: UnrecognizedSetterEvent(metadata: .init( + setter: setter, + pageViewId: pageViewId + ))) + } + + /// An error occurred deserializing the JSON payload from a web message. + /// - Parameters: + /// - message: The name of the message + /// - error: The error thrown deserializing the message + func logDeserializeMessageErrorEvent(message: String, error: Error) { + log(event: DeserializeMessageErrorEvent(metadata: .init( + message: message, + error: error, + pageViewId: pageViewId + ))) + } + + /// An authenticated web view was opened + /// - Parameter id: ID for the authenticated web view session (sent in `openAuthenticatedWebView` message + func logAuthenticatedWebViewOpenedEvent(id: String) { + log(event: AuthenticatedWebViewOpenedEvent(metadata: .init( + authenticatedWebViewId: id, + pageViewId: pageViewId + ))) + } + + /// The authenticated web view either successfully redirected or was canceled by the user + /// - Parameters: + /// - id: ID for the authenticated web view session (sent in + /// `openAuthenticatedWebView` message + /// - redirected: True when the authenticated web view successfully redirected back to the app. + /// False if the user closed the view before getting directed back to the app. + func logAuthenticatedWebViewEventComplete(id: String, redirected: Bool) { + if redirected { + log(event: AuthenticatedWebViewRedirectedEvent(metadata: .init( + authenticatedWebViewId: id, + pageViewId: pageViewId + ))) + } else { + log(event: AuthenticatedWebViewCanceledEvent(metadata: .init( + authenticatedWebViewId: id, + pageViewId: pageViewId + ))) + } + } + + /// The authenticated web view threw an error and was not successfully redirected back to the app. + /// - Parameters: + /// - id: ID for the authenticated web view session (sent in + /// `openAuthenticatedWebView` message + /// - error: The error thrown by the authenticated web view + func logAuthenticatedWebViewEventComplete(id: String, error: Error) { + log(event: AuthenticatedWebViewErrorEvent(metadata: .init( + authenticatedWebViewId: id, + error: error, + pageViewId: pageViewId + ))) + } + + /** + Catch-all for mobile client-side errors + - Parameters: + - error: Error to log. + - file: File name the error was caught on. + - line: File line number the error was caught on. + + - Note: If the error type conforms to `AnalyticLoggableErrorV2` then all + properties returned by `analyticLoggableSerializeForLogging()` will be encoded + into the event payload. Otherwise only domain and code will be encoded. + + File and line number should be explicitly passed if this method is called + from a helper function, otherwise it's difficult to determine where the + original error was caught. + */ + func logClientError(_ error: Error, + file: StaticString = #file, + line: UInt = #line) { + var params: [String: Any] = [ + "error": error.analyticsIdentifier, + "file": ("\(file)" as NSString).lastPathComponent, + "line": line, + ] + if let loggableError = error as? AnalyticLoggableErrorV2 { + params.mergeAssertingOnOverwrites(loggableError.serializeForV2Logging()) + } + + client.log( + eventName: "client_error", + parameters: params + ) + } +} + +extension ComponentAnalyticsClient.CommonFields { + init(apiClient: STPAPIClient, + component: ComponentType, + componentInstance: UUID = .init() + ) { + // Reuse logic in ConnectJSURLParams to determine when to use publicKey + // platformId + livemode + let params = ConnectJSURLParams(component: component, apiClient: apiClient) + + // Ensures a secret key is never logged to analytics in the event + // the platform uses a secret key in their app + var publicKey = params.publicKey + if publicKey != nil { + // Check for nil so we don't log '[REDACTED_LIVE_KEY]' if we don't + // intend to log any key (e.g. for user keys) + publicKey = apiClient.sanitizedPublishableKey + } + + self.init( + publishableKey: publicKey, + platformId: params.platformIdOverride, + merchantId: params.merchantIdOverride, + livemode: params.livemodeOverride, + component: params.component, + componentInstance: componentInstance + ) + } +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Analytics/ConnectAnalyticEvent.swift b/StripeConnect/StripeConnect/Source/Internal/Analytics/ConnectAnalyticEvent.swift new file mode 100644 index 00000000000..d54a7fac6ef --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Analytics/ConnectAnalyticEvent.swift @@ -0,0 +1,17 @@ +// +// ConnectAnalyticEvent.swift +// StripeConnect +// +// Created by Mel Ludowise on 10/1/24. +// + +/// Represents an analytics event logged from the Connect SDK +protocol ConnectAnalyticEvent { + associatedtype Metadata: Encodable + + /// The `event_name` field of the event + var name: String { get } + + /// Event-specific metadata, encoded as a JSON string in the `metadata` field + var metadata: Metadata { get } +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/AuthenticatedWebViewCanceledEvent.swift b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/AuthenticatedWebViewCanceledEvent.swift new file mode 100644 index 00000000000..e4b3421f5bc --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/AuthenticatedWebViewCanceledEvent.swift @@ -0,0 +1,21 @@ +// +// AuthenticatedWebViewCanceledEvent.swift +// StripeConnect +// +// Created by Mel Ludowise on 11/6/24. +// + +/// The user closed the authenticated web view before getting redirected back to the app. +struct AuthenticatedWebViewCanceledEvent: ConnectAnalyticEvent { + struct Metadata: Encodable { + /// ID for the authenticated web view session (sent in `openAuthenticatedWebView` message + let authenticatedWebViewId: String + + /// The `pageViewID` from the web view + /// - Note: May be null if not yet sent from web + let pageViewId: String? + } + + let name = "component.authenticated_web.canceled" + let metadata: Metadata +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/AuthenticatedWebViewErrorEvent.swift b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/AuthenticatedWebViewErrorEvent.swift new file mode 100644 index 00000000000..de132e4f98c --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/AuthenticatedWebViewErrorEvent.swift @@ -0,0 +1,30 @@ +// +// AuthenticatedWebViewErrorEvent.swift +// StripeConnect +// +// Created by Mel Ludowise on 11/6/24. +// + +/// The authenticated web view threw an error and was not successfully redirected back to the app. +struct AuthenticatedWebViewErrorEvent: ConnectAnalyticEvent { + struct Metadata: Encodable { + /// ID for the authenticated web view session (sent in `openAuthenticatedWebView` message + let authenticatedWebViewId: String + + /// The error identifier + let error: String + + /// The `pageViewID` from the web view + /// - Note: May be null if not yet sent from web + let pageViewId: String? + + init(authenticatedWebViewId: String, error: Error, pageViewId: String?) { + self.authenticatedWebViewId = authenticatedWebViewId + self.error = error.analyticsIdentifier + self.pageViewId = pageViewId + } + } + + let name = "component.authenticated_web.error" + let metadata: Metadata +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/AuthenticatedWebViewOpenedEvent.swift b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/AuthenticatedWebViewOpenedEvent.swift new file mode 100644 index 00000000000..af9e0c5ea50 --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/AuthenticatedWebViewOpenedEvent.swift @@ -0,0 +1,21 @@ +// +// AuthenticatedWebViewOpenedEvent.swift +// StripeConnect +// +// Created by Mel Ludowise on 11/6/24. +// + +/// An authenticated web view was opened +struct AuthenticatedWebViewOpenedEvent: ConnectAnalyticEvent { + struct Metadata: Encodable { + /// ID for the authenticated web view session (sent in `openAuthenticatedWebView` message + let authenticatedWebViewId: String + + /// The `pageViewID` from the web view + /// - Note: May be null if not yet sent from web + let pageViewId: String? + } + + let name = "component.authenticated_web.opened" + let metadata: Metadata +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/AuthenticatedWebViewRedirectedEvent.swift b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/AuthenticatedWebViewRedirectedEvent.swift new file mode 100644 index 00000000000..3f18b6610db --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/AuthenticatedWebViewRedirectedEvent.swift @@ -0,0 +1,21 @@ +// +// AuthenticatedWebViewRedirectedEvent.swift +// StripeConnect +// +// Created by Mel Ludowise on 11/6/24. +// + +/// The authenticated web view successfully redirected back to the app +struct AuthenticatedWebViewRedirectedEvent: ConnectAnalyticEvent { + struct Metadata: Encodable { + /// ID for the authenticated web view session (sent in `openAuthenticatedWebView` message + let authenticatedWebViewId: String + + /// The `pageViewID` from the web view + /// - Note: May be null if not yet sent from web + let pageViewId: String? + } + + let name = "component.authenticated_web.redirected" + let metadata: Metadata +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/ComponentCreatedEvent.swift b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/ComponentCreatedEvent.swift new file mode 100644 index 00000000000..5d5401f101a --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/ComponentCreatedEvent.swift @@ -0,0 +1,14 @@ +// +// ComponentCreatedEvent.swift +// StripeConnect +// +// Created by Mel Ludowise on 10/4/24. +// + +/// A component was instantiated via `create{ComponentType}`. +struct ComponentCreatedEvent: ConnectAnalyticEvent { + struct Metadata: Encodable { } + + let name = "component.created" + let metadata = Metadata() +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/ComponentLoadedEvent.swift b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/ComponentLoadedEvent.swift new file mode 100644 index 00000000000..05f9fb7a0d3 --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/ComponentLoadedEvent.swift @@ -0,0 +1,28 @@ +// +// ComponentLoadedEvent.swift +// StripeConnect +// +// Created by Mel Ludowise on 10/4/24. +// + +/// The component is successfully loaded within the web view. +/// Triggered from `componentDidLoad` message handler from the web view. +struct ComponentLoadedEvent: ConnectAnalyticEvent { + struct Metadata: Encodable { + /// The pageViewID from the web view + let pageViewId: String? + + /// Elapsed time in seconds it took the component to load + /// (starting when the page first began loading). + let timeToLoad: TimeInterval + + /// Elapsed time in seconds in took between when the component was + /// initially viewed on screen (`component.viewed`) to when the component + /// finished loading. This value will be `0` if the component finished + /// loading before being viewed on screen. + let perceivedTimeToLoad: TimeInterval + } + + let name = "component.web.component_loaded" + let metadata: Metadata +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/ComponentViewedEvent.swift b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/ComponentViewedEvent.swift new file mode 100644 index 00000000000..c4db99b1973 --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/ComponentViewedEvent.swift @@ -0,0 +1,14 @@ +// +// ComponentViewedEvent.swift +// StripeConnect +// +// Created by Mel Ludowise on 10/4/24. +// + +/// The component is viewed on screen (`viewDidAppear` lifecycle event) +struct ComponentViewedEvent: ConnectAnalyticEvent { + struct Metadata: Encodable { } + + let name = "component.viewed" + let metadata = Metadata() +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/ComponentWebPageLoadedEvent.swift b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/ComponentWebPageLoadedEvent.swift new file mode 100644 index 00000000000..aef6f8c956f --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/ComponentWebPageLoadedEvent.swift @@ -0,0 +1,18 @@ +// +// ComponentWebPageLoadedEvent.swift +// StripeConnect +// +// Created by Mel Ludowise on 10/4/24. +// + +/// The web page finished loading (`didFinish navigation` event). +struct ComponentWebPageLoadedEvent: ConnectAnalyticEvent { + struct Metadata: Encodable { + /// Elapsed time in seconds it took the web page to load + /// (starting when it first began loading). + let timeToLoad: TimeInterval + } + + let name = "component.web.page_loaded" + let metadata: Metadata +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/DeserializeMessageErrorEvent.swift b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/DeserializeMessageErrorEvent.swift new file mode 100644 index 00000000000..ef9227dea9a --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/DeserializeMessageErrorEvent.swift @@ -0,0 +1,34 @@ +// +// DeserializeMessageErrorEvent.swift +// StripeConnect +// +// Created by Mel Ludowise on 10/4/24. +// + +/// An error occurred deserializing the JSON payload from a web message. +struct DeserializeMessageErrorEvent: ConnectAnalyticEvent { + struct Metadata: Encodable { + /// The name of the message + let message: String + + /// The error identifier + let error: String + + /// The error's description, if there is one. + let errorDescription: String? + + /// The `pageViewID` from the web view + /// - Note: May be null if not yet sent from web + let pageViewId: String? + + init(message: String, error: Error, pageViewId: String?) { + self.message = message + self.error = error.analyticsIdentifier + self.errorDescription = (error as NSError).userInfo[NSDebugDescriptionErrorKey] as? String + self.pageViewId = pageViewId + } + } + + let name = "component.web.error.deserialize_message" + let metadata: Metadata +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/PageLoadErrorEvent.swift b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/PageLoadErrorEvent.swift new file mode 100644 index 00000000000..4c47848c134 --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/PageLoadErrorEvent.swift @@ -0,0 +1,34 @@ +// +// PageLoadErrorEvent.swift +// StripeConnect +// +// Created by Mel Ludowise on 10/4/24. +// + +/// The SDK receives a non-200 status code or error loading the web view, other than “Internet connectivity” errors. +struct PageLoadErrorEvent: ConnectAnalyticEvent { + struct Metadata: Encodable { + /// http status code if the error was a non-200 response + let status: Int? + + /// Error identifier for non http status type errors + let error: String? + + /// The URL of the page, excluding hashtag params + let url: String? + + init(error: Error, url: URL?) { + if let statusError = error as? HTTPStatusError { + self.status = statusError.errorCode + self.error = nil + } else { + self.status = nil + self.error = error.analyticsIdentifier + } + self.url = url?.absoluteStringRemovingParams + } + } + + let name = "component.web.error.page_load" + let metadata: Metadata +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/UnexpectedLoadErrorTypeEvent.swift b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/UnexpectedLoadErrorTypeEvent.swift new file mode 100644 index 00000000000..b574dbc968a --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/UnexpectedLoadErrorTypeEvent.swift @@ -0,0 +1,21 @@ +// +// UnexpectedLoadErrorTypeEvent.swift +// StripeConnect +// +// Created by Mel Ludowise on 10/4/24. +// + +/// The web view sends an onLoadError that can’t be deserialized by the SDK. +struct UnexpectedLoadErrorTypeEvent: ConnectAnalyticEvent { + struct Metadata: Encodable { + /// The error `type` property from web + let errorType: String + + /// The pageViewID from the web view + /// - Note: May be null if not yet sent from web + let pageViewId: String? + } + + let name = "component.web.warn.unexpected_load_error_type" + let metadata: Metadata +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/UnrecognizedSetterEvent.swift b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/UnrecognizedSetterEvent.swift new file mode 100644 index 00000000000..79f88702638 --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Analytics/Events/UnrecognizedSetterEvent.swift @@ -0,0 +1,21 @@ +// +// UnrecognizedSetterEvent.swift +// StripeConnect +// +// Created by Mel Ludowise on 10/4/24. +// + +/// If the web view calls `onSetterFunctionCalled` with a `setter` argument the SDK doesn’t know how to handle. +struct UnrecognizedSetterEvent: ConnectAnalyticEvent { + struct Metadata: Encodable { + /// The `setter` property sent from web + let setter: String + + /// The pageViewID from the web view + /// - Note: May be null if not yet sent from web + let pageViewId: String? + } + + let name = "component.web.warn.unrecognized_setter_function" + let metadata: Metadata +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Extensions/Encodable+Connect.swift b/StripeConnect/StripeConnect/Source/Internal/Extensions/Encodable+Connect.swift new file mode 100644 index 00000000000..2552e6f8570 --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Extensions/Encodable+Connect.swift @@ -0,0 +1,30 @@ +// +// Encodable+Connect.swift +// StripeConnect +// +// Created by Mel Ludowise on 10/4/24. +// + +private enum JSONSerializationError: Int, Error { + /// The encoded object was expected to be a dictionary but turned out to be a single value + case expectedDictionary = 0 +} + +extension Encodable { + /// Encodes to a JSON serialized object with the given encoder and options + func jsonObject( + with encoder: JSONEncoder + ) throws -> Any { + let data = try encoder.encode(self) + return try JSONSerialization.jsonObject(with: data, options: .allowFragments) + } + + /// Encodes to a JSON dictionary with the given encoder + func jsonDictionary(with encoder: JSONEncoder) throws -> [String: Any] { + let json = try jsonObject(with: encoder) + guard let dict = json as? [String: Any] else { + throw JSONSerializationError.expectedDictionary + } + return dict + } +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Extensions/Error+extensions.swift b/StripeConnect/StripeConnect/Source/Internal/Extensions/Error+extensions.swift new file mode 100644 index 00000000000..9a30b1b806a --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Extensions/Error+extensions.swift @@ -0,0 +1,13 @@ +// +// Error+extensions.swift +// StripeConnect +// +// Created by Mel Ludowise on 10/4/24. +// + +extension Error { + var analyticsIdentifier: String { + let nsError = self as NSError + return "\(nsError.domain):\(nsError.code)" + } +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Extensions/JSONEncoder+extension.swift b/StripeConnect/StripeConnect/Source/Internal/Extensions/JSONEncoder+extension.swift index ad319b76301..70a356b71c0 100644 --- a/StripeConnect/StripeConnect/Source/Internal/Extensions/JSONEncoder+extension.swift +++ b/StripeConnect/StripeConnect/Source/Internal/Extensions/JSONEncoder+extension.swift @@ -8,10 +8,19 @@ import Foundation extension JSONEncoder { - static var connectEncoder: JSONEncoder { + /// Encoder used for JS Messaging and URL param encoding + static let connectEncoder: JSONEncoder = { let encoder = JSONEncoder() // Ensure keys are sorted for test stability. encoder.outputFormatting = .sortedKeys return encoder - } + }() + + /// Encoder used for analytics + static let analyticsEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + encoder.dateEncodingStrategy = .secondsSince1970 + return encoder + }() } diff --git a/StripeConnect/StripeConnect/Source/Internal/Extensions/URL+extension.swift b/StripeConnect/StripeConnect/Source/Internal/Extensions/URL+extension.swift new file mode 100644 index 00000000000..8e528315042 --- /dev/null +++ b/StripeConnect/StripeConnect/Source/Internal/Extensions/URL+extension.swift @@ -0,0 +1,21 @@ +// +// URL+extension.swift +// StripeConnect +// +// Created by Mel Ludowise on 10/4/24. +// + +extension URL { + /// Removes query and hashtag params from the absolute URL. + /// - Note: Used for logging sanitized URLs to analytics or to compare URLs without query args + var absoluteStringRemovingParams: String { + // Remove query params + var components = URLComponents(url: self, resolvingAgainstBaseURL: true) + components?.queryItems = nil + + let absoluteString = components?.url?.absoluteString ?? self.absoluteString + + // Remove hashtag params + return absoluteString.split(separator: "#").first.map(String.init) ?? "" + } +} diff --git a/StripeConnect/StripeConnect/Source/Internal/Webview/ApplicationURLOpener.swift b/StripeConnect/StripeConnect/Source/Internal/Webview/ApplicationURLOpener.swift index ada8cc09bb4..f2467099bd1 100644 --- a/StripeConnect/StripeConnect/Source/Internal/Webview/ApplicationURLOpener.swift +++ b/StripeConnect/StripeConnect/Source/Internal/Webview/ApplicationURLOpener.swift @@ -5,8 +5,18 @@ // Created by Chris Mays on 8/14/24. // +@_spi(STP) import StripeCore import UIKit +/// Error thrown when the system can't open a URL. +/// Used for analytics logging. +struct URLOpenError: Error, AnalyticLoggableErrorV2 { + let url: URL + + func analyticLoggableSerializeForLogging() -> [String: Any] { + ["url": url.absoluteStringRemovingParams] + } +} /// Protocol used to dependency inject `UIApplication.open` for use in tests protocol ApplicationURLOpener { func canOpenURL(_ url: URL) -> Bool @@ -26,10 +36,9 @@ extension ApplicationURLOpener { typealias OpenCompletionHandler = (Bool) -> Void #endif - func openIfPossible(_ url: URL) { + func openIfPossible(_ url: URL) throws { guard canOpenURL(url) else { - // TODO: MXMOBILE-2491 Log as analytics when url can't be opened. - return + throw URLOpenError(url: url) } open(url, options: [:], completionHandler: nil) } diff --git a/StripeConnect/StripeConnect/Source/Internal/Webview/ConnectComponentWebViewController.swift b/StripeConnect/StripeConnect/Source/Internal/Webview/ConnectComponentWebViewController.swift index 7f8f75be32b..a6660846246 100644 --- a/StripeConnect/StripeConnect/Source/Internal/Webview/ConnectComponentWebViewController.swift +++ b/StripeConnect/StripeConnect/Source/Internal/Webview/ConnectComponentWebViewController.swift @@ -28,7 +28,7 @@ class ConnectComponentWebViewController: ConnectWebViewController { /// Manages authenticated web views private let authenticatedWebViewManager: AuthenticatedWebViewManager - private let setterMessageHandler: OnSetterFunctionCalledMessageHandler = .init() + private lazy var setterMessageHandler: OnSetterFunctionCalledMessageHandler = .init(analyticsClient: analyticsClient) private var didFailLoadWithError: (Error) -> Void @@ -43,6 +43,7 @@ class ConnectComponentWebViewController: ConnectWebViewController { componentManager: EmbeddedComponentManager, componentType: ComponentType, loadContent: Bool, + analyticsClientFactory: ComponentAnalyticsClientFactory, fetchInitProps: @escaping () -> InitProps, didFailLoadWithError: @escaping (Error) -> Void, // Should only be overridden for tests @@ -66,7 +67,13 @@ class ConnectComponentWebViewController: ConnectWebViewController { // embedded YouTube videos. config.allowsInlineMediaPlayback = true - super.init(configuration: config) + super.init( + configuration: config, + analyticsClient: analyticsClientFactory(.init( + apiClient: componentManager.apiClient, + component: componentType + )) + ) // Setup views webView.addSubview(activityIndicator) @@ -83,11 +90,19 @@ class ConnectComponentWebViewController: ConnectWebViewController { addMessageHandlers(fetchInitProps: fetchInitProps) addNotificationObservers() + // Log created event + analyticsClient.log(event: ComponentCreatedEvent()) + // Load the web page if loadContent { activityIndicator.startAnimating() - let url = ConnectJSURLParams(component: componentType, apiClient: componentManager.apiClient).url - webView.load(.init(url: url)) + do { + let url = try ConnectJSURLParams(component: componentType, apiClient: componentManager.apiClient).url() + analyticsClient.loadStart = .now + webView.load(.init(url: url)) + } catch { + showAlertAndLog(error: error) + } } } @@ -95,6 +110,7 @@ class ConnectComponentWebViewController: ConnectWebViewController { convenience init(componentManager: EmbeddedComponentManager, componentType: ComponentType, loadContent: Bool, + analyticsClientFactory: ComponentAnalyticsClientFactory, didFailLoadWithError: @escaping (Error) -> Void, // Should only be overridden for tests notificationCenter: NotificationCenter = NotificationCenter.default, @@ -103,6 +119,7 @@ class ConnectComponentWebViewController: ConnectWebViewController { self.init(componentManager: componentManager, componentType: componentType, loadContent: loadContent, + analyticsClientFactory: analyticsClientFactory, fetchInitProps: VoidPayload.init, didFailLoadWithError: didFailLoadWithError, notificationCenter: notificationCenter, @@ -123,19 +140,41 @@ class ConnectComponentWebViewController: ConnectWebViewController { } } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + analyticsClient.logComponentViewed(viewedAt: .now) + } + // MARK: - ConnectWebViewController + override func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + super.webView(webView, didFinish: navigation) + + analyticsClient.logComponentWebPageLoaded(loadEnd: .now) + } + override func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) { super.webView(webView, didFail: navigation, withError: error) + didFailLoad(error: error) - // TODO: MXMOBILE-2491 log error + analyticsClient.log(event: PageLoadErrorEvent(metadata: .init( + error: error, + url: webView.url + ))) } override func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy { + // If the component web page fails to load with an HTTP error, send a + // load failure to event if let response = navigationResponse.response as? HTTPURLResponse, + response.url?.absoluteStringRemovingParams == StripeConnectConstants.connectJSBaseURL.absoluteString, response.hasErrorStatus { - didFailLoad(error: HTTPStatusError(errorCode: response.statusCode)) - // TODO: MXMOBILE-2491 log error + let error = HTTPStatusError(errorCode: response.statusCode) + didFailLoad(error: error) + analyticsClient.log(event: PageLoadErrorEvent(metadata: .init( + error: error, + url: response.url + ))) } return await super.webView(webView, decidePolicyFor: navigationResponse) @@ -164,8 +203,11 @@ extension ConnectComponentWebViewController { /// Convenience method to send messages to the webview. func sendMessage(_ sender: any MessageSender) { - if let message = sender.javascriptMessage { + do { + let message = try sender.javascriptMessage() webView.evaluateJavaScript(message) + } catch { + analyticsClient.logClientError(error) } } @@ -184,7 +226,8 @@ private extension ConnectComponentWebViewController { fetchInitProps: @escaping () -> InitProps ) { addMessageHandler(setterMessageHandler) - addMessageHandler(OnLoaderStartMessageHandler { [activityIndicator] _ in + addMessageHandler(OnLoaderStartMessageHandler { [analyticsClient, activityIndicator] _ in + analyticsClient.logComponentLoaded(loadEnd: .now) activityIndicator.stopAnimating() }) addMessageHandler(FetchInitParamsMessageHandler.init(didReceiveMessage: {[weak self] _ in @@ -198,20 +241,20 @@ private extension ConnectComponentWebViewController { fonts: componentManager.fonts.map({ .init(customFontSource: $0) })) })) addMessageHandler(FetchInitComponentPropsMessageHandler(fetchInitProps)) - addMessageHandler(OnLoadErrorMessageHandler { [weak self] value in - self?.didFailLoad(error: value.error.connectEmbedError) + addMessageHandler(OnLoadErrorMessageHandler { [weak self, analyticsClient] value in + self?.didFailLoad(error: value.error.connectEmbedError(analyticsClient: analyticsClient)) }) - addMessageHandler(DebugMessageHandler()) + addMessageHandler(DebugMessageHandler(analyticsClient: analyticsClient)) addMessageHandler(FetchClientSecretMessageHandler { [weak self] _ in await self?.componentManager.fetchClientSecret() }) - addMessageHandler(PageDidLoadMessageHandler { _ in - // TODO: MXMOBILE-2491 Use this for analytics + addMessageHandler(PageDidLoadMessageHandler(analyticsClient: analyticsClient) { [analyticsClient] payload in + analyticsClient.pageViewId = payload.pageViewId }) - addMessageHandler(AccountSessionClaimedMessageHandler{ _ in - // TODO: MXMOBILE-2491 Use this for analytics + addMessageHandler(AccountSessionClaimedMessageHandler(analyticsClient: analyticsClient) { [analyticsClient] payload in + analyticsClient.merchantId = payload.merchantId }) - addMessageHandler(OpenAuthenticatedWebViewMessageHandler { [weak self] payload in + addMessageHandler(OpenAuthenticatedWebViewMessageHandler(analyticsClient: analyticsClient) { [weak self] payload in self?.openAuthenticatedWebView(payload) }) } @@ -244,13 +287,15 @@ private extension ConnectComponentWebViewController { func openAuthenticatedWebView(_ payload: OpenAuthenticatedWebViewMessageHandler.Payload) { Task { @MainActor in do { - // TODO: MXMOBILE-2491 log `component.authenticated_web.*` analytic + analyticsClient.logAuthenticatedWebViewOpenedEvent(id: payload.id) + let returnUrl = try await authenticatedWebViewManager.present(with: payload.url, from: view) + analyticsClient.logAuthenticatedWebViewEventComplete(id: payload.id, redirected: returnUrl != nil) + sendMessage(ReturnedFromAuthenticatedWebViewSender(payload: .init(url: returnUrl, id: payload.id))) } catch { - // TODO: MXMOBILE-2491 log `component.authenticated_web.error` analytic - debugPrint("Error returning from authenticated web view: \(error)") + analyticsClient.logAuthenticatedWebViewEventComplete(id: payload.id, error: error) } } } diff --git a/StripeConnect/StripeConnect/Source/Internal/Webview/ConnectWebViewController.swift b/StripeConnect/StripeConnect/Source/Internal/Webview/ConnectWebViewController.swift index 49e715b2bdf..45a5053f2eb 100644 --- a/StripeConnect/StripeConnect/Source/Internal/Webview/ConnectWebViewController.swift +++ b/StripeConnect/StripeConnect/Source/Internal/Webview/ConnectWebViewController.swift @@ -9,6 +9,11 @@ import SafariServices @_spi(STP) import StripeCore import WebKit +enum ConnectWebViewControllerError: Int, Error { + case downloadFileDoesNotExist + case multipleDownloads +} + /** Custom implementation of a web view that handles: - Camera access @@ -30,14 +35,19 @@ class ConnectWebViewController: UIViewController { /// The file manager responsible for creating temporary file directories to store downloads let fileManager: FileManager + /// The analytics client used to log load errors + let analyticsClient: ComponentAnalyticsClient + /// The current version for the SDK let sdkVersion: String? init(configuration: WKWebViewConfiguration, + analyticsClient: ComponentAnalyticsClient, // Only override for tests urlOpener: ApplicationURLOpener = UIApplication.shared, fileManager: FileManager = .default, sdkVersion: String? = StripeAPIConfiguration.STPSDKVersion) { + self.analyticsClient = analyticsClient self.urlOpener = urlOpener self.fileManager = fileManager self.sdkVersion = sdkVersion @@ -60,6 +70,8 @@ class ConnectWebViewController: UIViewController { fatalError("init(coder:) has not been implemented") } + // MARK: - UIViewController + override func loadView() { view = webView } @@ -70,6 +82,20 @@ class ConnectWebViewController: UIViewController { // Default disable swipe to dismiss parent?.isModalInPresentation = true } + + // MARK: - Internal + + func showAlertAndLog(error: Error, + file: StaticString = #file, + line: UInt = #line) { + analyticsClient.logClientError(error, file: file, line: line) + + let alert = UIAlertController( + title: nil, + message: NSError.stp_unexpectedErrorMessage(), + preferredStyle: .alert) + present(alert, animated: true) + } } // MARK: - Private @@ -80,6 +106,7 @@ private extension ConnectWebViewController { func openInPopup(configuration: WKWebViewConfiguration, navigationAction: WKNavigationAction) -> WKWebView? { let popupVC = PopupWebViewController(configuration: configuration, + analyticsClient: analyticsClient, navigationAction: navigationAction, urlOpener: urlOpener, sdkVersion: sdkVersion) @@ -102,18 +129,11 @@ private extension ConnectWebViewController { // Opens with UIApplication.open, if supported func openOnSystem(url: URL) { - urlOpener.openIfPossible(url) - } - - func showErrorAlert(for error: Error?) { - // TODO: MXMOBILE-2491 Log analytic when receiving an error - debugPrint(String(describing: error)) - - let alert = UIAlertController( - title: nil, - message: NSError.stp_unexpectedErrorMessage(), - preferredStyle: .alert) - present(alert, animated: true) + do { + try urlOpener.openIfPossible(url) + } catch { + analyticsClient.logClientError(error) + } } func cleanupDownloadedFile() { @@ -182,6 +202,10 @@ extension ConnectWebViewController: WKUIDelegate { @available(iOS 15, *) extension ConnectWebViewController: WKNavigationDelegate { + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + // Override from subclass + } + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) { // Override from subclass } @@ -248,10 +272,7 @@ extension ConnectWebViewController { func download(decideDestinationUsing response: URLResponse, suggestedFilename: String) async -> URL? { if downloadedFile != nil { - // If there's already a downloaded file, it means there were multiple - // simultaneous downloads or we didn't clean up the URL correctly - // TODO: MXMOBILE-2491 Log error analytic - debugPrint("Multiple downloads") + analyticsClient.logClientError(ConnectWebViewControllerError.multipleDownloads) } // The temporary filename must be unique or the download will fail. @@ -263,7 +284,7 @@ extension ConnectWebViewController { do { try fileManager.createDirectory(at: tempDir, withIntermediateDirectories: true) } catch { - showErrorAlert(for: error) + showAlertAndLog(error: error) return nil } @@ -273,7 +294,7 @@ extension ConnectWebViewController { func download(didFailWithError error: any Error, resumeData: Data?) { - showErrorAlert(for: error) + showAlertAndLog(error: error) } func downloadDidFinish() { @@ -282,8 +303,7 @@ extension ConnectWebViewController { // `downloadedFile` should never be nil here // If file doesn't exist, it indicates something went wrong creating // the temp file or the system deleted the temp file too quickly - // TODO: MXMOBILE-2491 Log error analytic - showErrorAlert(for: nil) + showAlertAndLog(error: ConnectWebViewControllerError.downloadFileDoesNotExist) cleanupDownloadedFile() return } diff --git a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/AccountSessionClaimedMessageHandler.swift b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/AccountSessionClaimedMessageHandler.swift index d016195cbee..c063ff8427c 100644 --- a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/AccountSessionClaimedMessageHandler.swift +++ b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/AccountSessionClaimedMessageHandler.swift @@ -13,7 +13,10 @@ class AccountSessionClaimedMessageHandler: ScriptMessageHandler Void) { - super.init(name: "accountSessionClaimed", didReceiveMessage: didReceiveMessage) + init(analyticsClient: ComponentAnalyticsClient, + didReceiveMessage: @escaping (Payload) -> Void) { + super.init(name: "accountSessionClaimed", + analyticsClient: analyticsClient, + didReceiveMessage: didReceiveMessage) } } diff --git a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/DebugMessageHandler.swift b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/DebugMessageHandler.swift index 35a492fef11..401cbcb6e70 100644 --- a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/DebugMessageHandler.swift +++ b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/DebugMessageHandler.swift @@ -9,7 +9,10 @@ import Foundation // Emitted when the SDK should print to the console in debug mode. class DebugMessageHandler: ScriptMessageHandler { - init(didReceiveMessage: @escaping (String) -> Void = { Swift.debugPrint($0) }) { - super.init(name: "debug", didReceiveMessage: didReceiveMessage) + init(analyticsClient: ComponentAnalyticsClient, + didReceiveMessage: @escaping (String) -> Void = { Swift.debugPrint($0) }) { + super.init(name: "debug", + analyticsClient: analyticsClient, + didReceiveMessage: didReceiveMessage) } } diff --git a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/Helpers/OnSetterFunctionCalledMessageHandler.swift b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/Helpers/OnSetterFunctionCalledMessageHandler.swift index 60f4abe16da..3b5e85ce8c1 100644 --- a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/Helpers/OnSetterFunctionCalledMessageHandler.swift +++ b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/Helpers/OnSetterFunctionCalledMessageHandler.swift @@ -64,9 +64,11 @@ class OnSetterFunctionCalledMessageHandler: ScriptMessageHandler: NSObject, WKScriptMessageHandler { + struct UnexpectedMessageNameError: Error, AnalyticLoggableErrorV2 { + let actual: String + let expected: String + + func analyticLoggableSerializeForLogging() -> [String: Any] { + [ + "actual": actual, + "expected": expected, + ] + } + } + let name: String let didReceiveMessage: (Payload) -> Void + let analyticsClient: ComponentAnalyticsClient init(name: String, + analyticsClient: ComponentAnalyticsClient, didReceiveMessage: @escaping (Payload) -> Void) { self.name = name self.didReceiveMessage = didReceiveMessage + self.analyticsClient = analyticsClient } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == name else { - debugPrint("Unexpected message name: \(message.name)") + analyticsClient.logClientError(UnexpectedMessageNameError( + actual: message.name, + expected: name + )) return } do { didReceiveMessage(try message.toDecodable()) } catch { - // TODO: MXMOBILE-2491 Log as analytics - debugPrint("Failed to decode body for message with name: \(message.name) \(error.localizedDescription)") + analyticsClient.logDeserializeMessageErrorEvent(message: message.name, error: error) } } } diff --git a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/Helpers/ScriptMessageHandlerWithReply.swift b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/Helpers/ScriptMessageHandlerWithReply.swift index 3b37b184354..f43fa3e99e4 100644 --- a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/Helpers/ScriptMessageHandlerWithReply.swift +++ b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/Helpers/ScriptMessageHandlerWithReply.swift @@ -28,12 +28,8 @@ class ScriptMessageHandlerWithReply: NS do { let payload: Payload = try message.toDecodable() let value = try await didReceiveMessage(payload) - let responseData = try JSONEncoder.connectEncoder.encode(value) - - guard let response = try? JSONSerialization.jsonObject(with: responseData, options: .allowFragments) else { - return (nil, "Failed to encode response") - } + let response = try value.jsonObject(with: .connectEncoder) return (response, nil) } catch { debugPrint("Error processing message: \((error as NSError).debugDescription)") diff --git a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/OnLoadErrorMessageHandler.swift b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/OnLoadErrorMessageHandler.swift index 8481dfba650..efdf21fdb5b 100644 --- a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/OnLoadErrorMessageHandler.swift +++ b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/OnLoadErrorMessageHandler.swift @@ -8,10 +8,13 @@ import Foundation extension OnLoadErrorMessageHandler.Values.ErrorValue { - var connectEmbedError: EmbeddedComponentError { + func connectEmbedError(analyticsClient: ComponentAnalyticsClient) -> EmbeddedComponentError { // API Error is a catch all so defer to that if we get an unknown type. - // TODO(MXMOBILE-2491): Log error analytic if `type` is unrecognized - .init(type: .init(rawValue: type) ?? .apiError, description: message) + let errorType = EmbeddedComponentError.ErrorType(rawValue: type) + if errorType == nil { + analyticsClient.logUnexpectedLoadErrorType(type: type) + } + return .init(type: .init(rawValue: type) ?? .apiError, description: message) } } diff --git a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/OpenAuthenticatedWebViewMessageHandler.swift b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/OpenAuthenticatedWebViewMessageHandler.swift index 53a7900a459..893f75b0232 100644 --- a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/OpenAuthenticatedWebViewMessageHandler.swift +++ b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/OpenAuthenticatedWebViewMessageHandler.swift @@ -15,7 +15,10 @@ class OpenAuthenticatedWebViewMessageHandler: ScriptMessageHandler Void) { - super.init(name: "openAuthenticatedWebView", didReceiveMessage: didReceiveMessage) + init(analyticsClient: ComponentAnalyticsClient, + didReceiveMessage: @escaping (Payload) -> Void) { + super.init(name: "openAuthenticatedWebView", + analyticsClient: analyticsClient, + didReceiveMessage: didReceiveMessage) } } diff --git a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/PageDidLoadMessageHandler.swift b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/PageDidLoadMessageHandler.swift index 69b5602ed9b..7ec8718c54f 100644 --- a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/PageDidLoadMessageHandler.swift +++ b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageHandlers/PageDidLoadMessageHandler.swift @@ -13,7 +13,10 @@ class PageDidLoadMessageHandler: ScriptMessageHandler Void) { - super.init(name: "pageDidLoad", didReceiveMessage: didReceiveMessage) + init(analyticsClient: ComponentAnalyticsClient, + didReceiveMessage: @escaping (Payload) -> Void) { + super.init(name: "pageDidLoad", + analyticsClient: analyticsClient, + didReceiveMessage: didReceiveMessage) } } diff --git a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageSenders/MessageSender.swift b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageSenders/MessageSender.swift index dca24085af7..75628da5c7a 100644 --- a/StripeConnect/StripeConnect/Source/Internal/Webview/MessageSenders/MessageSender.swift +++ b/StripeConnect/StripeConnect/Source/Internal/Webview/MessageSenders/MessageSender.swift @@ -7,6 +7,11 @@ import Foundation +private enum MessageSenderError: Int, Error { + /// Error encoding the json to utf-8 + case stringEncoding +} + /// Sends a message to the webview by calling a function on `window` protocol MessageSender { associatedtype Payload: Encodable @@ -17,11 +22,10 @@ protocol MessageSender { } extension MessageSender { - var javascriptMessage: String? { - guard let jsonData = try? JSONEncoder.connectEncoder.encode(payload), - let jsonString = String(data: jsonData, encoding: .utf8) else { - // TODO: MXMOBILE-2491 Log failure to analytics - return nil + func javascriptMessage() throws -> String { + let jsonData = try JSONEncoder.connectEncoder.encode(payload) + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw MessageSenderError.stringEncoding } return "window.\(name)(\(jsonString));" } diff --git a/StripeConnect/StripeConnect/Source/Internal/Webview/PopupWebViewController.swift b/StripeConnect/StripeConnect/Source/Internal/Webview/PopupWebViewController.swift index 93fea7c59e3..15ae5d1dbb1 100644 --- a/StripeConnect/StripeConnect/Source/Internal/Webview/PopupWebViewController.swift +++ b/StripeConnect/StripeConnect/Source/Internal/Webview/PopupWebViewController.swift @@ -16,12 +16,14 @@ class PopupWebViewController: ConnectWebViewController { private var titleObserver: NSKeyValueObservation? init(configuration: WKWebViewConfiguration, + analyticsClient: ComponentAnalyticsClient, navigationAction: WKNavigationAction, urlOpener: ApplicationURLOpener = UIApplication.shared, sdkVersion: String? = StripeAPIConfiguration.STPSDKVersion) { super.init(configuration: configuration, - urlOpener: urlOpener, - sdkVersion: sdkVersion) + analyticsClient: analyticsClient, + urlOpener: urlOpener, + sdkVersion: sdkVersion) webView.load(navigationAction.request) // Keep navbar title in sync with web view diff --git a/StripeConnect/StripeConnectTests/Components/AccountManagementViewControllerTests.swift b/StripeConnect/StripeConnectTests/Components/AccountManagementViewControllerTests.swift index 9027a61be47..65fde0a62dc 100644 --- a/StripeConnect/StripeConnectTests/Components/AccountManagementViewControllerTests.swift +++ b/StripeConnect/StripeConnectTests/Components/AccountManagementViewControllerTests.swift @@ -20,6 +20,7 @@ class AccountManagementViewControllerTests: XCTestCase { super.setUp() STPAPIClient.shared.publishableKey = "pk_test" componentManager.shouldLoadContent = false + componentManager.analyticsClientFactory = MockComponentAnalyticsClient.init } @MainActor diff --git a/StripeConnect/StripeConnectTests/Components/AccountOnboardingViewControllerTests.swift b/StripeConnect/StripeConnectTests/Components/AccountOnboardingViewControllerTests.swift index afae58f21c9..32e2aef3226 100644 --- a/StripeConnect/StripeConnectTests/Components/AccountOnboardingViewControllerTests.swift +++ b/StripeConnect/StripeConnectTests/Components/AccountOnboardingViewControllerTests.swift @@ -20,6 +20,7 @@ class AccountOnboardingViewControllerTests: XCTestCase { super.setUp() STPAPIClient.shared.publishableKey = "pk_test" componentManager.shouldLoadContent = false + componentManager.analyticsClientFactory = MockComponentAnalyticsClient.init } @MainActor diff --git a/StripeConnect/StripeConnectTests/Components/NotificationBannerViewControllerTests.swift b/StripeConnect/StripeConnectTests/Components/NotificationBannerViewControllerTests.swift index 885428c03fe..df37c21aeaa 100644 --- a/StripeConnect/StripeConnectTests/Components/NotificationBannerViewControllerTests.swift +++ b/StripeConnect/StripeConnectTests/Components/NotificationBannerViewControllerTests.swift @@ -20,6 +20,7 @@ class NotificationBannerViewControllerTests: XCTestCase { super.setUp() STPAPIClient.shared.publishableKey = "pk_test" componentManager.shouldLoadContent = false + componentManager.analyticsClientFactory = MockComponentAnalyticsClient.init } @MainActor diff --git a/StripeConnect/StripeConnectTests/Components/PayoutsViewControllerTests.swift b/StripeConnect/StripeConnectTests/Components/PayoutsViewControllerTests.swift index a5199eeba8e..6ceb3df8ef8 100644 --- a/StripeConnect/StripeConnectTests/Components/PayoutsViewControllerTests.swift +++ b/StripeConnect/StripeConnectTests/Components/PayoutsViewControllerTests.swift @@ -34,6 +34,7 @@ class PayoutsViewControllerTests: XCTestCase { super.setUp() STPAPIClient.shared.publishableKey = "pk_test" componentManager.shouldLoadContent = false + componentManager.analyticsClientFactory = MockComponentAnalyticsClient.init } @MainActor diff --git a/StripeConnect/StripeConnectTests/Helpers/ComponentAnalyticsClient+Mock.swift b/StripeConnect/StripeConnectTests/Helpers/ComponentAnalyticsClient+Mock.swift new file mode 100644 index 00000000000..9dce9bf5fa7 --- /dev/null +++ b/StripeConnect/StripeConnectTests/Helpers/ComponentAnalyticsClient+Mock.swift @@ -0,0 +1,12 @@ +// +// ComponentAnalyticsClient+Mock.swift +// StripeConnect +// +// Created by Mel Ludowise on 11/7/24. +// + +@testable import StripeConnect + +extension ComponentAnalyticsClient.CommonFields { + static let mock = ComponentAnalyticsClient.CommonFields(platformId: nil, livemode: nil, component: .onboarding, componentInstance: .init()) +} diff --git a/StripeConnect/StripeConnectTests/Helpers/MockComponentAnalyticsClient.swift b/StripeConnect/StripeConnectTests/Helpers/MockComponentAnalyticsClient.swift new file mode 100644 index 00000000000..f524f1d6e8b --- /dev/null +++ b/StripeConnect/StripeConnectTests/Helpers/MockComponentAnalyticsClient.swift @@ -0,0 +1,18 @@ +// +// MockComponentAnalyticsClient.swift +// StripeConnect +// +// Created by Mel Ludowise on 11/7/24. +// + +@testable import StripeConnect +@_spi(STP) import StripeCoreTestUtils +import XCTest + +class MockComponentAnalyticsClient: ComponentAnalyticsClient { + init(commonFields: CommonFields) { + super.init(client: MockAnalyticsClientV2(), commonFields: commonFields) + } + + // TODO: Add test helpers +} diff --git a/StripeConnect/StripeConnectTests/Internal/Webview/ConnectComponentWebViewControllerTests.swift b/StripeConnect/StripeConnectTests/Internal/Webview/ConnectComponentWebViewControllerTests.swift index 204ada941e6..a6bc5ba8718 100644 --- a/StripeConnect/StripeConnectTests/Internal/Webview/ConnectComponentWebViewControllerTests.swift +++ b/StripeConnect/StripeConnectTests/Internal/Webview/ConnectComponentWebViewControllerTests.swift @@ -25,6 +25,7 @@ class ConnectComponentWebViewControllerTests: XCTestCase { let webVC = ConnectComponentWebViewController(componentManager: componentManager, componentType: .payouts, loadContent: false, + analyticsClientFactory: MockComponentAnalyticsClient.init, didFailLoadWithError: { _ in }) try await webVC.webView.evaluateMessageWithReply(name: "fetchClientSecret", @@ -39,6 +40,7 @@ class ConnectComponentWebViewControllerTests: XCTestCase { let webVC = ConnectComponentWebViewController(componentManager: componentManager, componentType: .payouts, loadContent: false, + analyticsClientFactory: MockComponentAnalyticsClient.init, didFailLoadWithError: { _ in }, webLocale: Locale(identifier: "fr_FR")) @@ -53,6 +55,7 @@ class ConnectComponentWebViewControllerTests: XCTestCase { let webVC = ConnectComponentWebViewController(componentManager: componentManager, componentType: .payouts, loadContent: false, + analyticsClientFactory: MockComponentAnalyticsClient.init, didFailLoadWithError: { _ in }, webLocale: Locale(identifier: "fr_FR")) var appearance = EmbeddedComponentManager.Appearance() @@ -77,10 +80,11 @@ class ConnectComponentWebViewControllerTests: XCTestCase { let componentManager = componentManagerAssertingOnFetch(appearance: appearance) let webVC = ConnectComponentWebViewController(componentManager: componentManager, - componentType: .payouts, - loadContent: false, - didFailLoadWithError: { _ in }, - webLocale: Locale(identifier: "fr_FR")) + componentType: .payouts, + loadContent: false, + analyticsClientFactory: MockComponentAnalyticsClient.init, + didFailLoadWithError: { _ in }, + webLocale: Locale(identifier: "fr_FR")) webVC.triggerTraitCollectionChange(style: .dark) @@ -98,6 +102,7 @@ class ConnectComponentWebViewControllerTests: XCTestCase { let webVC = ConnectComponentWebViewController(componentManager: componentManager, componentType: .payouts, loadContent: false, + analyticsClientFactory: MockComponentAnalyticsClient.init, didFailLoadWithError: { _ in }, webLocale: Locale(identifier: "fr_FR")) @@ -116,6 +121,7 @@ class ConnectComponentWebViewControllerTests: XCTestCase { let webVC = ConnectComponentWebViewController(componentManager: componentManager, componentType: .payouts, loadContent: false, + analyticsClientFactory: MockComponentAnalyticsClient.init, didFailLoadWithError: { _ in }, webLocale: Locale(identifier: "fr_FR")) @@ -137,6 +143,7 @@ class ConnectComponentWebViewControllerTests: XCTestCase { componentManager: componentManager, componentType: .payouts, loadContent: false, + analyticsClientFactory: MockComponentAnalyticsClient.init, didFailLoadWithError: { _ in }, notificationCenter: notificationCenter, webLocale: Locale(identifier: "fr_FR")) @@ -157,6 +164,7 @@ class ConnectComponentWebViewControllerTests: XCTestCase { let webVC = ConnectComponentWebViewController(componentManager: componentManager, componentType: .payouts, loadContent: false, + analyticsClientFactory: MockComponentAnalyticsClient.init, didFailLoadWithError: { _ in }, webLocale: Locale(identifier: "fr_FR")) @@ -173,6 +181,7 @@ class ConnectComponentWebViewControllerTests: XCTestCase { let webVC = ConnectComponentWebViewController(componentManager: componentManager, componentType: .payouts, loadContent: false, + analyticsClientFactory: MockComponentAnalyticsClient.init, didFailLoadWithError: { _ in }) // Mock that loading indicator is animating webVC.activityIndicator.startAnimating() @@ -190,6 +199,7 @@ class ConnectComponentWebViewControllerTests: XCTestCase { let webVC = ConnectComponentWebViewController(componentManager: componentManager, componentType: .payouts, loadContent: false, + analyticsClientFactory: MockComponentAnalyticsClient.init, didFailLoadWithError: { error = $0 }) // Mock that loading indicator is animating webVC.activityIndicator.startAnimating() @@ -206,6 +216,7 @@ class ConnectComponentWebViewControllerTests: XCTestCase { let webVC = ConnectComponentWebViewController(componentManager: componentManager, componentType: .payouts, loadContent: false, + analyticsClientFactory: MockComponentAnalyticsClient.init, didFailLoadWithError: { error = $0 }) // Mock that loading indicator is animating webVC.activityIndicator.startAnimating() @@ -223,8 +234,9 @@ class ConnectComponentWebViewControllerTests: XCTestCase { let webVC = ConnectComponentWebViewController(componentManager: componentManager, componentType: .payouts, loadContent: false, + analyticsClientFactory: MockComponentAnalyticsClient.init, didFailLoadWithError: { error = $0 }) - _ = await webVC.webView(webVC.webView, decidePolicyFor: MockNavigationResponse(response: HTTPURLResponse(url: URL(string: "https://stripe.com")!, statusCode: 404, httpVersion: nil, headerFields: nil)!)) + _ = await webVC.webView(webVC.webView, decidePolicyFor: MockNavigationResponse(response: HTTPURLResponse(url: URL(string: "https://connect-js.stripe.com/v1.0/ios_webview.html")!, statusCode: 404, httpVersion: nil, headerFields: nil)!)) XCTAssertEqual((error as? HTTPStatusError)?.errorCode, 404) // Loading indicator should stop XCTAssertFalse(webVC.activityIndicator.isAnimating) @@ -239,6 +251,7 @@ class ConnectComponentWebViewControllerTests: XCTestCase { let webVC = ConnectComponentWebViewController(componentManager: componentManager, componentType: .payouts, loadContent: false, + analyticsClientFactory: MockComponentAnalyticsClient.init, didFailLoadWithError: { _ in }, authenticatedWebViewManager: authenticatedWebViewManager) diff --git a/StripeConnect/StripeConnectTests/Internal/Webview/ConnectWebViewControllerTests.swift b/StripeConnect/StripeConnectTests/Internal/Webview/ConnectWebViewControllerTests.swift index 7ef0ca5b5f6..95412fdb1e3 100644 --- a/StripeConnect/StripeConnectTests/Internal/Webview/ConnectWebViewControllerTests.swift +++ b/StripeConnect/StripeConnectTests/Internal/Webview/ConnectWebViewControllerTests.swift @@ -17,13 +17,16 @@ class ConnectWebViewControllerTests: XCTestCase { private var mockURLOpener: MockURLOpener! private var mockFileManager: MockFileManager! + private var mockAnalyticsClient: MockComponentAnalyticsClient! private var webVC: ConnectWebViewControllerTestWrapper! override func setUp() { super.setUp() mockFileManager = .init() mockURLOpener = .init() + mockAnalyticsClient = .init(commonFields: .mock) webVC = .init(configuration: .init(), + analyticsClient: mockAnalyticsClient, urlOpener: mockURLOpener, fileManager: mockFileManager, sdkVersion: "1.2.3") @@ -316,7 +319,7 @@ class MockNavigationResponse: WKNavigationResponse { } } -private class MockURLOpener: ApplicationURLOpener { +class MockURLOpener: ApplicationURLOpener { var canOpenURLOverride: ((_ url: URL) -> Bool)? var openURLOverride: ((_ url: URL, _ options: [UIApplication.OpenExternalURLOptionsKey: Any], _ completion: OpenCompletionHandler?) -> Void)? @@ -361,7 +364,7 @@ private class MockFileManager: FileManager { } private class ConnectWebViewControllerTestWrapper: ConnectWebViewController { - var presentPopup: (UIViewController) -> Void = {_ in } + var presentPopup: (UIViewController) -> Void = { _ in } override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { presentPopup(viewControllerToPresent) diff --git a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/AccountSessionClaimedMessageHandlerTests.swift b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/AccountSessionClaimedMessageHandlerTests.swift index 9756d76934e..ca5bbbb0c29 100644 --- a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/AccountSessionClaimedMessageHandlerTests.swift +++ b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/AccountSessionClaimedMessageHandlerTests.swift @@ -13,10 +13,13 @@ class AccountSessionClaimedMessageHandlerTests: ScriptWebTestBase { let expectation = self.expectation(description: "Message received") let merchantId = "acct_1234" - webView.addMessageHandler(messageHandler: AccountSessionClaimedMessageHandler(didReceiveMessage: { payload in - expectation.fulfill() - XCTAssertEqual(payload, .init(merchantId: merchantId)) - })) + webView.addMessageHandler(messageHandler: AccountSessionClaimedMessageHandler( + analyticsClient: MockComponentAnalyticsClient(commonFields: .mock), + didReceiveMessage: { payload in + expectation.fulfill() + XCTAssertEqual(payload, .init(merchantId: merchantId)) + } + )) webView.evaluateAccountSessionClaimed(merchantId: merchantId) waitForExpectations(timeout: TestHelpers.defaultTimeout, handler: nil) diff --git a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/DebugMessageHandlerTests.swift b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/DebugMessageHandlerTests.swift index b7445cad1e3..f574db6ab50 100644 --- a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/DebugMessageHandlerTests.swift +++ b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/DebugMessageHandlerTests.swift @@ -13,10 +13,13 @@ class DebugMessageHandlerTests: ScriptWebTestBase { let expectation = self.expectation(description: "Message received") let debugMessage = "test message" - webView.addMessageHandler(messageHandler: DebugMessageHandler(didReceiveMessage: { payload in - expectation.fulfill() - XCTAssertEqual(payload, debugMessage) - })) + webView.addMessageHandler(messageHandler: DebugMessageHandler( + analyticsClient: MockComponentAnalyticsClient(commonFields: .mock), + didReceiveMessage: { payload in + expectation.fulfill() + XCTAssertEqual(payload, debugMessage) + } + )) webView.evaluateDebugMessage(message: debugMessage) diff --git a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/NotificationBanner/OnNotificationsChangeHandlerTests.swift b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/NotificationBanner/OnNotificationsChangeHandlerTests.swift index f0e09242b83..81df4525bcf 100644 --- a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/NotificationBanner/OnNotificationsChangeHandlerTests.swift +++ b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/NotificationBanner/OnNotificationsChangeHandlerTests.swift @@ -12,7 +12,7 @@ class OnNotificationsChangeHandlerTests: ScriptWebTestBase { @MainActor func testMessageSend() async throws { let expectation = self.expectation(description: "Message received") - let messageHandler = OnSetterFunctionCalledMessageHandler() + let messageHandler = OnSetterFunctionCalledMessageHandler(analyticsClient: MockComponentAnalyticsClient(commonFields: .mock)) messageHandler.addHandler(handler: OnNotificationsChangeHandler(didReceiveMessage: { payload in expectation.fulfill() diff --git a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/OnLoadErrorMessageHandlerTests.swift b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/OnLoadErrorMessageHandlerTests.swift index b92497c4308..79b32b54d0e 100644 --- a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/OnLoadErrorMessageHandlerTests.swift +++ b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/OnLoadErrorMessageHandlerTests.swift @@ -12,7 +12,7 @@ class OnLoadErrorMessageHandlerTests: ScriptWebTestBase { @MainActor func testMessageSend() async throws { let expectation = self.expectation(description: "Message received") - let messageHandler = OnSetterFunctionCalledMessageHandler() + let messageHandler = OnSetterFunctionCalledMessageHandler(analyticsClient: MockComponentAnalyticsClient(commonFields: .mock)) messageHandler.addHandler(handler: OnLoadErrorMessageHandler(didReceiveMessage: { payload in expectation.fulfill() diff --git a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/OnLoaderStartMessageHandlerTests.swift b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/OnLoaderStartMessageHandlerTests.swift index 60b0fd0db08..88df372c8d6 100644 --- a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/OnLoaderStartMessageHandlerTests.swift +++ b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/OnLoaderStartMessageHandlerTests.swift @@ -13,7 +13,7 @@ class OnLoaderStartMessageHandlerTests: ScriptWebTestBase { func testMessageSend() async throws { let expectation = self.expectation(description: "Message received") - let messageHandler = OnSetterFunctionCalledMessageHandler() + let messageHandler = OnSetterFunctionCalledMessageHandler(analyticsClient: MockComponentAnalyticsClient(commonFields: .mock)) messageHandler.addHandler(handler: OnLoaderStartMessageHandler(didReceiveMessage: { payload in expectation.fulfill() diff --git a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/OnSetterFunctionCalledMessageHandlerTests.swift b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/OnSetterFunctionCalledMessageHandlerTests.swift index 8a1abdf7742..1925bd15b13 100644 --- a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/OnSetterFunctionCalledMessageHandlerTests.swift +++ b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/OnSetterFunctionCalledMessageHandlerTests.swift @@ -12,7 +12,7 @@ class OnSetterFunctionCalledMessageHandlerTests: ScriptWebTestBase { func testDeallocation() { weak var weakInstance: OnSetterFunctionCalledMessageHandler? autoreleasepool { - let instance = OnSetterFunctionCalledMessageHandler() + let instance = OnSetterFunctionCalledMessageHandler(analyticsClient: MockComponentAnalyticsClient(commonFields: .mock)) weakInstance = instance XCTAssertNotNil(weakInstance) } diff --git a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/Onboarding/OnExitMessageHandlerTests.swift b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/Onboarding/OnExitMessageHandlerTests.swift index 10f103fd830..7a4fb13552c 100644 --- a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/Onboarding/OnExitMessageHandlerTests.swift +++ b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/Onboarding/OnExitMessageHandlerTests.swift @@ -12,7 +12,7 @@ class OnExitMessageHandlerTests: ScriptWebTestBase { @MainActor func testMessageSend() async throws { let expectation = self.expectation(description: "Message received") - let messageHandler = OnSetterFunctionCalledMessageHandler() + let messageHandler = OnSetterFunctionCalledMessageHandler(analyticsClient: MockComponentAnalyticsClient(commonFields: .mock)) messageHandler.addHandler(handler: OnExitMessageHandler(didReceiveMessage: { expectation.fulfill() diff --git a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/OpenAuthenticatedWebViewMessageHandlerTests.swift b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/OpenAuthenticatedWebViewMessageHandlerTests.swift index 3382be6c61e..30932d769b3 100644 --- a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/OpenAuthenticatedWebViewMessageHandlerTests.swift +++ b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/OpenAuthenticatedWebViewMessageHandlerTests.swift @@ -13,10 +13,13 @@ class OpenAuthenticatedWebViewMessageHandlerTests: ScriptWebTestBase { let expectation = self.expectation(description: "Message received") let url = "https://dashboard.stripe.com" let id = "1234" - webView.addMessageHandler(messageHandler: OpenAuthenticatedWebViewMessageHandler(didReceiveMessage: { payload in - expectation.fulfill() - XCTAssertEqual(payload, .init(url: URL(string: "https://dashboard.stripe.com")!, id: id)) - })) + webView.addMessageHandler(messageHandler: OpenAuthenticatedWebViewMessageHandler( + analyticsClient: MockComponentAnalyticsClient(commonFields: .mock), + didReceiveMessage: { payload in + expectation.fulfill() + XCTAssertEqual(payload, .init(url: URL(string: "https://dashboard.stripe.com")!, id: id)) + } + )) webView.evaluateOpenAuthenticatedWebView(url: url, id: id) diff --git a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/PageDidLoadMessageHandlerTests.swift b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/PageDidLoadMessageHandlerTests.swift index 29678b9b9e9..fbf2e273f66 100644 --- a/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/PageDidLoadMessageHandlerTests.swift +++ b/StripeConnect/StripeConnectTests/Internal/Webview/MessageHandlers/PageDidLoadMessageHandlerTests.swift @@ -14,10 +14,13 @@ class PageDidLoadMessageHandlerTests: ScriptWebTestBase { let pageViewId = "123" - webView.addMessageHandler(messageHandler: PageDidLoadMessageHandler(didReceiveMessage: { payload in - expectation.fulfill() - XCTAssertEqual(payload, .init(pageViewId: pageViewId)) - })) + webView.addMessageHandler(messageHandler: PageDidLoadMessageHandler( + analyticsClient: MockComponentAnalyticsClient(commonFields: .mock), + didReceiveMessage: { payload in + expectation.fulfill() + XCTAssertEqual(payload, .init(pageViewId: pageViewId)) + } + )) webView.evaluatePageDidLoad(pageViewId: pageViewId) diff --git a/StripeConnect/StripeConnectTests/Internal/Webview/MessageSenders/CallSetterWithSerializableValueSenderTests.swift b/StripeConnect/StripeConnectTests/Internal/Webview/MessageSenders/CallSetterWithSerializableValueSenderTests.swift index 6dd379d3e50..7ebc80a8f52 100644 --- a/StripeConnect/StripeConnectTests/Internal/Webview/MessageSenders/CallSetterWithSerializableValueSenderTests.swift +++ b/StripeConnect/StripeConnectTests/Internal/Webview/MessageSenders/CallSetterWithSerializableValueSenderTests.swift @@ -14,9 +14,9 @@ class CallSetterWithSerializableValueSenderTests: ScriptWebTestBase { try validateMessageSent(sender: CallSetterWithSerializableValueSender(payload: .init(setter: "setPayment", value: "pi_1234"))) } - func testSenderSignature() { + func testSenderSignature() throws { XCTAssertEqual( - CallSetterWithSerializableValueSender(payload: .init(setter: "setPayment", value: "pi_1234")).javascriptMessage, + try CallSetterWithSerializableValueSender(payload: .init(setter: "setPayment", value: "pi_1234")).javascriptMessage(), """ window.callSetterWithSerializableValue({"setter":"setPayment","value":"pi_1234"}); """ diff --git a/StripeConnect/StripeConnectTests/Internal/Webview/MessageSenders/ReturnedFromAuthenticatedWebViewSenderTests.swift b/StripeConnect/StripeConnectTests/Internal/Webview/MessageSenders/ReturnedFromAuthenticatedWebViewSenderTests.swift index 88216bca75a..66cad0ee0e4 100644 --- a/StripeConnect/StripeConnectTests/Internal/Webview/MessageSenders/ReturnedFromAuthenticatedWebViewSenderTests.swift +++ b/StripeConnect/StripeConnectTests/Internal/Webview/MessageSenders/ReturnedFromAuthenticatedWebViewSenderTests.swift @@ -16,7 +16,7 @@ class ReturnedFromAuthenticatedWebViewSenderTests: ScriptWebTestBase { func testSenderSignature() { XCTAssertEqual( - ReturnedFromAuthenticatedWebViewSender(payload: .init(url: URL(string: "https://dashboard.stripe.com")!, id: "123")).javascriptMessage, + try ReturnedFromAuthenticatedWebViewSender(payload: .init(url: URL(string: "https://dashboard.stripe.com")!, id: "123")).javascriptMessage(), """ window.returnedFromAuthenticatedWebView({"id":"123","url":"https:\\/\\/dashboard.stripe.com"}); """ diff --git a/StripeConnect/StripeConnectTests/Internal/Webview/MessageSenders/UpdateConnectInstanceSenderTests.swift b/StripeConnect/StripeConnectTests/Internal/Webview/MessageSenders/UpdateConnectInstanceSenderTests.swift index 949b10cac33..75dedc3d842 100644 --- a/StripeConnect/StripeConnectTests/Internal/Webview/MessageSenders/UpdateConnectInstanceSenderTests.swift +++ b/StripeConnect/StripeConnectTests/Internal/Webview/MessageSenders/UpdateConnectInstanceSenderTests.swift @@ -16,7 +16,7 @@ class UpdateConnectInstanceSenderTests: ScriptWebTestBase { func testSenderSignature() { XCTAssertEqual( - UpdateConnectInstanceSender(payload: .init(locale: "en", appearance: .default)).javascriptMessage, + try UpdateConnectInstanceSender(payload: .init(locale: "en", appearance: .default)).javascriptMessage(), """ window.updateConnectInstance({"appearance":{"variables":{"fontFamily":"-apple-system","fontSizeBase":"16px"}},"locale":"en"}); """ diff --git a/StripeConnect/StripeConnectTests/WebView+Tests.swift b/StripeConnect/StripeConnectTests/WebView+Tests.swift index 2ec38d1cc92..f8bcbba9dd2 100644 --- a/StripeConnect/StripeConnectTests/WebView+Tests.swift +++ b/StripeConnect/StripeConnectTests/WebView+Tests.swift @@ -115,7 +115,7 @@ extension WKWebView { } func sendMessage(sender: Sender) throws { - evaluateJavaScript(try XCTUnwrap(sender.javascriptMessage)) { (_, error) in + evaluateJavaScript(try sender.javascriptMessage()) { (_, error) in if let error { XCTFail("JavaScript execution failed: \(error)") } diff --git a/StripeCore/StripeCore/Source/API Bindings/STPAPIClient.swift b/StripeCore/StripeCore/Source/API Bindings/STPAPIClient.swift index 49de3b5026d..dcd7cdd505b 100644 --- a/StripeCore/StripeCore/Source/API Bindings/STPAPIClient.swift +++ b/StripeCore/StripeCore/Source/API Bindings/STPAPIClient.swift @@ -43,7 +43,7 @@ import UIKit /// A publishable key that only contains publishable keys and not secret keys. /// /// If a secret key is found, returns "[REDACTED_LIVE_KEY]". - var sanitizedPublishableKey: String? { + @_spi(STP) public var sanitizedPublishableKey: String? { guard let publishableKey = publishableKey else { return nil } diff --git a/StripeCore/StripeCore/Source/Analytics/AnalyticsClientV2.swift b/StripeCore/StripeCore/Source/Analytics/AnalyticsClientV2.swift index f50b9db9f63..b1f5592b4ff 100644 --- a/StripeCore/StripeCore/Source/Analytics/AnalyticsClientV2.swift +++ b/StripeCore/StripeCore/Source/Analytics/AnalyticsClientV2.swift @@ -90,7 +90,14 @@ import UIKit let payload = payload(withEventName: eventName, parameters: parameters) #if DEBUG - NSLog("LOG ANALYTICS: \(payload)") + let jsonString = String( + data: try! JSONSerialization.data( + withJSONObject: payload, + options: [.sortedKeys, .prettyPrinted] + ), + encoding: .utf8 + )! + NSLog("LOG ANALYTICS: \(jsonString)") #endif guard AnalyticsClientV2.shouldCollectAnalytics else {