From 95aaf748887a6f5fdef1e45e3f9b2da67b2ad97c Mon Sep 17 00:00:00 2001 From: Toni Rico Date: Mon, 21 Oct 2024 15:47:16 +0200 Subject: [PATCH] Support custom URL paths in `ManageSubscriptionsView` (#4382) * Support custom URL paths in ManageSubscriptionsView * Fix tests * More test fixes * Add parsing and processing tests * Consolidate SafariView * Rename method * Another rename * Fix import issue with SafariView not being compatible to some platforms * Style fix --- RevenueCat.xcodeproj/project.pbxproj | 4 ++ .../Data/CustomerCenterConfigTestData.swift | 10 +++++ .../ManageSubscriptionsViewModel.swift | 29 ++++++++++++ .../Views/ManageSubscriptionsView.swift | 6 +++ .../Button/ButtonComponentView.swift | 22 +--------- RevenueCatUI/Views/SafariView.swift | 32 ++++++++++++++ .../CustomerCenterConfigData.swift | 44 ++++++++++++++++++- .../CustomerCenterConfigResponse.swift | 18 ++++++++ .../ManageSubscriptionsViewModelTests.swift | 2 + .../CustomerCenterConfigDataTests.swift | 32 +++++++++++++- 10 files changed, 175 insertions(+), 24 deletions(-) create mode 100644 RevenueCatUI/Views/SafariView.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 33ea393095..30838b7cef 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 1E5F8F6E2C4515430041EECD /* View+PresentCustomerCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E5F8F6D2C4515430041EECD /* View+PresentCustomerCenter.swift */; }; 1E5F8F782C46BBD90041EECD /* CustomerCenterAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E5F8F772C46BBD90041EECD /* CustomerCenterAction.swift */; }; 1E99F81F2AC5917F0023E26E /* StoreMessagesHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E99F81D2AC5917F0023E26E /* StoreMessagesHelperTests.swift */; }; + 1ED4CA9F2CC25A5F0021AB8F /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ED4CA9E2CC25A5F0021AB8F /* SafariView.swift */; }; 2C0B98CD2797070B00C5874F /* PromotionalOffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C0B98CC2797070B00C5874F /* PromotionalOffer.swift */; }; 2C2AEB0F2CA64E0E00A50F38 /* Template1Preview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2AEB0E2CA64E0E00A50F38 /* Template1Preview.swift */; }; 2C2AEB3B2CA7209F00A50F38 /* PaywallPackageComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C2AEB3A2CA7209F00A50F38 /* PaywallPackageComponent.swift */; }; @@ -1161,6 +1162,7 @@ 1E5F8F6D2C4515430041EECD /* View+PresentCustomerCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+PresentCustomerCenter.swift"; sourceTree = ""; }; 1E5F8F772C46BBD90041EECD /* CustomerCenterAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterAction.swift; sourceTree = ""; }; 1E99F81D2AC5917F0023E26E /* StoreMessagesHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreMessagesHelperTests.swift; sourceTree = ""; }; + 1ED4CA9E2CC25A5F0021AB8F /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; 2C0B98CC2797070B00C5874F /* PromotionalOffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromotionalOffer.swift; sourceTree = ""; }; 2C2AEB0E2CA64E0E00A50F38 /* Template1Preview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Template1Preview.swift; sourceTree = ""; }; 2C2AEB3A2CA7209F00A50F38 /* PaywallPackageComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPackageComponent.swift; sourceTree = ""; }; @@ -4204,6 +4206,7 @@ 887A605D2C1D037000E1A461 /* RemoteImage.swift */, 887A605E2C1D037000E1A461 /* TemplateBackgroundImageView.swift */, 88A543E62C37A4C40039C6A5 /* TierSelectorView.swift */, + 1ED4CA9E2CC25A5F0021AB8F /* SafariView.swift */, ); path = Views; sourceTree = ""; @@ -6063,6 +6066,7 @@ 88B1BB132C81479F001B7EE5 /* PaywallComponentTypeTransformers.swift in Sources */, 88B1BB042C813A3C001B7EE5 /* StackComponentViewModel.swift in Sources */, 1E5F8F782C46BBD90041EECD /* CustomerCenterAction.swift in Sources */, + 1ED4CA9F2CC25A5F0021AB8F /* SafariView.swift in Sources */, 887A60CC2C1D037000E1A461 /* PaywallFontProvider.swift in Sources */, 887A60B82C1D037000E1A461 /* Template1View.swift in Sources */, 887A60C62C1D037000E1A461 /* LoadingPaywallView.swift in Sources */, diff --git a/RevenueCatUI/CustomerCenter/Data/CustomerCenterConfigTestData.swift b/RevenueCatUI/CustomerCenter/Data/CustomerCenterConfigTestData.swift index 61b7e1ee4a..281ee6c6ca 100644 --- a/RevenueCatUI/CustomerCenter/Data/CustomerCenterConfigTestData.swift +++ b/RevenueCatUI/CustomerCenter/Data/CustomerCenterConfigTestData.swift @@ -31,12 +31,16 @@ enum CustomerCenterConfigTestData { .init( id: "1", title: "Didn't receive purchase", + url: nil, + openMethod: nil, type: .missingPurchase, detail: nil ), .init( id: "2", title: "Request a refund", + url: nil, + openMethod: nil, type: .refundRequest, detail: .promotionalOffer(CustomerCenterConfigData.HelpPath.PromotionalOffer( iosOfferId: "offer_id", @@ -48,12 +52,16 @@ enum CustomerCenterConfigTestData { .init( id: "3", title: "Change plans", + url: nil, + openMethod: nil, type: .changePlans, detail: nil ), .init( id: "4", title: "Cancel subscription", + url: nil, + openMethod: nil, type: .cancel, detail: .feedbackSurvey(.init( title: "Why are you cancelling?", @@ -86,6 +94,8 @@ enum CustomerCenterConfigTestData { .init( id: "9q9719171o", title: "Check purchases", + url: nil, + openMethod: nil, type: .missingPurchase, detail: nil ) diff --git a/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift b/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift index 88a4448993..d7dbc6f4f3 100644 --- a/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift +++ b/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift @@ -37,6 +37,8 @@ class ManageSubscriptionsViewModel: ObservableObject { @Published var promotionalOfferData: PromotionalOfferData? @Published + var inAppBrowserURL: IdentifiableURL? + @Published var state: CustomerCenterViewState { didSet { if case let .error(stateError) = state { @@ -152,10 +154,24 @@ class ManageSubscriptionsViewModel: ObservableObject { self.loadingPath = nil } } + + func onDismissInAppBrowser() { + self.inAppBrowserURL = nil + } #endif } +struct IdentifiableURL: Identifiable { + + var id: String { + return url.absoluteString + } + + let url: URL + +} + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) @available(macOS, unavailable) @available(tvOS, unavailable) @@ -163,6 +179,7 @@ class ManageSubscriptionsViewModel: ObservableObject { private extension ManageSubscriptionsViewModel { #if os(iOS) || targetEnvironment(macCatalyst) + // swiftlint:disable:next cyclomatic_complexity private func onPathSelected(path: CustomerCenterConfigData.HelpPath) async { switch path.type { case .missingPurchase: @@ -186,6 +203,18 @@ private extension ManageSubscriptionsViewModel { } catch { self.state = .error(error) } + case .customUrl: + guard let url = path.url, + let openMethod = path.openMethod else { + Logger.warning("Found a custom URL path without a URL or open method. Ignoring tap.") + return + } + switch openMethod { + case .external: + UIApplication.shared.open(url, options: [:], completionHandler: nil) + case .inApp: + self.inAppBrowserURL = .init(url: url) + } default: break } diff --git a/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift b/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift index cc6d679755..a14d20d067 100644 --- a/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift +++ b/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift @@ -120,6 +120,12 @@ struct ManageSubscriptionsView: View { product: promotionalOfferData.product, promoOfferDetails: promotionalOfferData.promoOfferDetails) }) + .sheet(item: self.$viewModel.inAppBrowserURL, + onDismiss: { + self.viewModel.onDismissInAppBrowser() + }, content: { inAppBrowserURL in + SafariView(url: inAppBrowserURL.url) + }) .navigationTitle(self.viewModel.screen.title) .navigationBarTitleDisplayMode(.inline) } diff --git a/RevenueCatUI/Templates/Components/Button/ButtonComponentView.swift b/RevenueCatUI/Templates/Components/Button/ButtonComponentView.swift index 8aa558b434..5158ca83e8 100644 --- a/RevenueCatUI/Templates/Components/Button/ButtonComponentView.swift +++ b/RevenueCatUI/Templates/Components/Button/ButtonComponentView.swift @@ -13,9 +13,6 @@ import Foundation import RevenueCat -#if canImport(SafariServices) -import SafariServices -#endif import SwiftUI #if PAYWALL_COMPONENTS @@ -42,7 +39,7 @@ struct ButtonComponentView: View { action: { try await performAction() }, label: { StackComponentView(viewModel: viewModel.stackViewModel, onDismiss: self.onDismiss) } ) - #if canImport(SafariServices) + #if canImport(SafariServices) && canImport(UIKit) .sheet(isPresented: .isNotNil($inAppBrowserURL)) { SafariView(url: inAppBrowserURL!) }.presentCustomerCenter(isPresented: $showCustomerCenter) { @@ -104,23 +101,6 @@ struct ButtonComponentView: View { } -#if canImport(SafariServices) -private struct SafariView: UIViewControllerRepresentable { - let url: URL - - func makeUIViewController(context: UIViewControllerRepresentableContext) -> SFSafariViewController { - SFSafariViewController(url: url) - } - - func updateUIViewController( - _ uiViewController: SFSafariViewController, - context: UIViewControllerRepresentableContext - ) { - // No updates needed - } -} -#endif - #if DEBUG @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) diff --git a/RevenueCatUI/Views/SafariView.swift b/RevenueCatUI/Views/SafariView.swift new file mode 100644 index 0000000000..07fe9bbe93 --- /dev/null +++ b/RevenueCatUI/Views/SafariView.swift @@ -0,0 +1,32 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// SafariView.swift +// +// Created by Antonio Rico Diez on 2024-10-18. + +#if canImport(SafariServices) && canImport(UIKit) + +import SafariServices +import SwiftUI + +struct SafariView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> SFSafariViewController { + return SFSafariViewController(url: url) + } + + func updateUIViewController(_ uiViewController: SFSafariViewController, + context: UIViewControllerRepresentableContext) { + // No need to update the controller, as it's static in this case + } +} + +#endif diff --git a/Sources/CustomerCenter/CustomerCenterConfigData.swift b/Sources/CustomerCenter/CustomerCenterConfigData.swift index e8fe125531..75d666dd29 100644 --- a/Sources/CustomerCenter/CustomerCenterConfigData.swift +++ b/Sources/CustomerCenter/CustomerCenterConfigData.swift @@ -177,15 +177,21 @@ public struct CustomerCenterConfigData { public let id: String public let title: String + public let url: URL? + public let openMethod: OpenMethod? public let type: PathType public let detail: PathDetail? public init(id: String, title: String, + url: URL?, + openMethod: OpenMethod?, type: PathType, detail: PathDetail?) { self.id = id self.title = title + self.url = url + self.openMethod = openMethod self.type = type self.detail = detail } @@ -203,6 +209,7 @@ public struct CustomerCenterConfigData { case refundRequest = "REFUND_REQUEST" case changePlans = "CHANGE_PLANS" case cancel = "CANCEL" + case customUrl = "CUSTOM_URL" case unknown init(from rawValue: String) { @@ -215,6 +222,8 @@ public struct CustomerCenterConfigData { self = .changePlans case "CANCEL": self = .cancel + case "CUSTOM_URL": + self = .customUrl default: self = .unknown } @@ -222,6 +231,24 @@ public struct CustomerCenterConfigData { } + public enum OpenMethod: String { + + case inApp = "IN_APP" + case external = "EXTERNAL" + + init?(from rawValue: String?) { + switch rawValue { + case "IN_APP": + self = .inApp + case "EXTERNAL": + self = .external + default: + return nil + } + } + + } + public struct PromotionalOffer { public let iosOfferId: String @@ -389,7 +416,7 @@ extension CustomerCenterConfigData.Screen { self.type = ScreenType(from: response.type.rawValue) self.title = response.title self.subtitle = response.subtitle - self.paths = response.paths.map { CustomerCenterConfigData.HelpPath(from: $0) } + self.paths = response.paths.compactMap { CustomerCenterConfigData.HelpPath(from: $0) } } } @@ -425,10 +452,23 @@ extension CustomerCenterConfigData.Localization { extension CustomerCenterConfigData.HelpPath { - init(from response: CustomerCenterConfigResponse.HelpPath) { + init?(from response: CustomerCenterConfigResponse.HelpPath) { self.id = response.id self.title = response.title self.type = CustomerCenterConfigData.HelpPath.PathType(from: response.type.rawValue) + if self.type == .customUrl { + if let responseUrl = response.url, + let url = URL(string: responseUrl), + let openMethod = CustomerCenterConfigData.HelpPath.OpenMethod(from: response.openMethod?.rawValue) { + self.url = url + self.openMethod = openMethod + } else { + return nil + } + } else { + self.url = nil + self.openMethod = nil + } if let promotionalOfferResponse = response.promotionalOffer { self.detail = .promotionalOffer(PromotionalOffer(from: promotionalOfferResponse)) } else if let feedbackSurveyResponse = response.feedbackSurvey { diff --git a/Sources/Networking/Responses/CustomerCenterConfigResponse.swift b/Sources/Networking/Responses/CustomerCenterConfigResponse.swift index 86df26c258..6db02eb9d4 100644 --- a/Sources/Networking/Responses/CustomerCenterConfigResponse.swift +++ b/Sources/Networking/Responses/CustomerCenterConfigResponse.swift @@ -44,6 +44,8 @@ struct CustomerCenterConfigResponse { let id: String let title: String let type: PathType + let url: String? + let openMethod: OpenMethod? let promotionalOffer: PromotionalOffer? let feedbackSurvey: FeedbackSurvey? @@ -53,6 +55,15 @@ struct CustomerCenterConfigResponse { case refundRequest = "REFUND_REQUEST" case changePlans = "CHANGE_PLANS" case cancel = "CANCEL" + case customUrl = "CUSTOM_URL" + case unknown + + } + + enum OpenMethod: String { + + case inApp = "IN_APP" + case external = "EXTERNAL" case unknown } @@ -130,6 +141,7 @@ extension CustomerCenterConfigResponse.CustomerCenter: Codable, Equatable {} extension CustomerCenterConfigResponse.Localization: Codable, Equatable {} extension CustomerCenterConfigResponse.HelpPath: Codable, Equatable {} extension CustomerCenterConfigResponse.HelpPath.PathType: Equatable {} +extension CustomerCenterConfigResponse.HelpPath.OpenMethod: Equatable {} extension CustomerCenterConfigResponse.HelpPath.PromotionalOffer: Codable, Equatable {} extension CustomerCenterConfigResponse.HelpPath.FeedbackSurvey: Codable, Equatable {} extension CustomerCenterConfigResponse.HelpPath.FeedbackSurvey.Option: Codable, Equatable {} @@ -167,4 +179,10 @@ extension CustomerCenterConfigResponse.HelpPath.PathType: CodableEnumWithUnknown } +extension CustomerCenterConfigResponse.HelpPath.OpenMethod: CodableEnumWithUnknownCase { + + static var unknownCase: Self { .unknown } + +} + extension CustomerCenterConfigResponse: HTTPResponseBody {} diff --git a/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift b/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift index e70571df59..99722490db 100644 --- a/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift +++ b/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift @@ -777,6 +777,8 @@ private class Fixtures { .init( id: "1", title: "Didn't receive purchase", + url: nil, + openMethod: nil, type: .missingPurchase, detail: .promotionalOffer(CustomerCenterConfigData.HelpPath.PromotionalOffer( iosOfferId: "offer_id", diff --git a/Tests/UnitTests/CustomerCenter/CustomerCenterConfigDataTests.swift b/Tests/UnitTests/CustomerCenter/CustomerCenterConfigDataTests.swift index f5c78b2dfe..cd7efdb921 100644 --- a/Tests/UnitTests/CustomerCenter/CustomerCenterConfigDataTests.swift +++ b/Tests/UnitTests/CustomerCenter/CustomerCenterConfigDataTests.swift @@ -47,6 +47,8 @@ class CustomerCenterConfigDataTests: TestCase { id: "path1", title: "Path 1", type: .missingPurchase, + url: nil, + openMethod: nil, promotionalOffer: nil, feedbackSurvey: nil ), @@ -54,6 +56,8 @@ class CustomerCenterConfigDataTests: TestCase { id: "path2", title: "Path 2", type: .cancel, + url: nil, + openMethod: nil, promotionalOffer: .init(iosOfferId: "offer_id", eligible: true, title: "Wait!", @@ -64,6 +68,8 @@ class CustomerCenterConfigDataTests: TestCase { id: "path3", title: "Path 3", type: .changePlans, + url: nil, + openMethod: nil, promotionalOffer: nil, feedbackSurvey: .init(title: "survey title", options: [ @@ -74,6 +80,18 @@ class CustomerCenterConfigDataTests: TestCase { title: "Wait!", subtitle: "Before you go")) ]) + ), + .init( + id: "path4", + title: "Path 4", + type: .customUrl, + url: "https://revenuecat.com", + openMethod: .external, + promotionalOffer: .init(iosOfferId: "offer_id", + eligible: true, + title: "Wait!", + subtitle: "Before you go"), + feedbackSurvey: nil ) ] ) @@ -106,18 +124,22 @@ class CustomerCenterConfigDataTests: TestCase { expect(managementScreen.type.rawValue) == "MANAGEMENT" expect(managementScreen.title) == "Management Screen" expect(managementScreen.subtitle) == "Manage your account" - expect(managementScreen.paths.count) == 3 + expect(managementScreen.paths.count) == 4 let paths = try XCTUnwrap(managementScreen.paths) expect(paths[0].id) == "path1" expect(paths[0].title) == "Path 1" expect(paths[0].type.rawValue) == "MISSING_PURCHASE" + expect(paths[0].url).to(beNil()) + expect(paths[0].openMethod).to(beNil()) expect(paths[0].detail).to(beNil()) expect(paths[1].id) == "path2" expect(paths[1].title) == "Path 2" expect(paths[1].type.rawValue) == "CANCEL" + expect(paths[1].url).to(beNil()) + expect(paths[1].openMethod).to(beNil()) if case let .promotionalOffer(promotionalOffer) = paths[1].detail { expect(promotionalOffer.iosOfferId) == "offer_id" expect(promotionalOffer.eligible).to(beTrue()) @@ -128,6 +150,8 @@ class CustomerCenterConfigDataTests: TestCase { expect(paths[2].id) == "path3" expect(paths[2].title) == "Path 3" expect(paths[2].type.rawValue) == "CHANGE_PLANS" + expect(paths[2].url).to(beNil()) + expect(paths[2].openMethod).to(beNil()) if case let .feedbackSurvey(feedbackSurvey) = paths[2].detail { expect(feedbackSurvey.title) == "survey title" expect(feedbackSurvey.options.count) == 1 @@ -137,6 +161,12 @@ class CustomerCenterConfigDataTests: TestCase { fail("Expected feedbackSurvey detail") } + expect(paths[3].id) == "path4" + expect(paths[3].title) == "Path 4" + expect(paths[3].type.rawValue) == "CUSTOM_URL" + expect(paths[3].url?.absoluteString) == "https://revenuecat.com" + expect(paths[3].openMethod) == .external + expect(configData.lastPublishedAppVersion) == "1.2.3" expect(configData.productId) == 123 }