Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Support custom URL paths in ManageSubscriptionsView #4382

Merged
merged 9 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1161,6 +1162,7 @@
1E5F8F6D2C4515430041EECD /* View+PresentCustomerCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+PresentCustomerCenter.swift"; sourceTree = "<group>"; };
1E5F8F772C46BBD90041EECD /* CustomerCenterAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterAction.swift; sourceTree = "<group>"; };
1E99F81D2AC5917F0023E26E /* StoreMessagesHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreMessagesHelperTests.swift; sourceTree = "<group>"; };
1ED4CA9E2CC25A5F0021AB8F /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = "<group>"; };
2C0B98CC2797070B00C5874F /* PromotionalOffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromotionalOffer.swift; sourceTree = "<group>"; };
2C2AEB0E2CA64E0E00A50F38 /* Template1Preview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Template1Preview.swift; sourceTree = "<group>"; };
2C2AEB3A2CA7209F00A50F38 /* PaywallPackageComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPackageComponent.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4204,6 +4206,7 @@
887A605D2C1D037000E1A461 /* RemoteImage.swift */,
887A605E2C1D037000E1A461 /* TemplateBackgroundImageView.swift */,
88A543E62C37A4C40039C6A5 /* TierSelectorView.swift */,
1ED4CA9E2CC25A5F0021AB8F /* SafariView.swift */,
);
path = Views;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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?",
Expand Down Expand Up @@ -86,6 +94,8 @@ enum CustomerCenterConfigTestData {
.init(
id: "9q9719171o",
title: "Check purchases",
url: nil,
openMethod: nil,
type: .missingPurchase,
detail: nil
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -152,17 +154,32 @@ class ManageSubscriptionsViewModel: ObservableObject {
self.loadingPath = nil
}
}

func onDismissInAppBrowser() {
self.inAppBrowserURL = nil
}
#endif

}

struct IdentifiableURL: Identifiable {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure if this is the best way but I couldn't make URL Identifiable directly without getting a warning.

Copy link
Member

Choose a reason for hiding this comment

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

Are you getting the warning that it will not behave correctly if URL ever becomes Identifiable in the future? If so, yea I think this solution is the safest!

Copy link
Contributor

Choose a reason for hiding this comment

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

I think it makes sense

nitpick but there should be an empty line at the beginning and at the end of the struct


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)
@available(watchOS, unavailable)
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:
Expand All @@ -186,6 +203,18 @@ private extension ManageSubscriptionsViewModel {
} catch {
self.state = .error(error)
}
case .customUrl:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note this is only currently available in the ManageSubscriptionView. It won't be available in the NoSubscriptionsView or WrongPlatformView since we're not using the paths for those views.

Copy link
Member

Choose a reason for hiding this comment

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

interesting... It might be worth figuring out a way for those to work eventually since for those it might make even more sense - you can direct users to where you'd actually want them to manage

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I think we need to move some of the logic to a shared view for all 3 of them and handle paths in all 3. Right now the buttons are mostly hardcoded in those 2 other views. Probably not something for this PR though.

Copy link
Contributor

Choose a reason for hiding this comment

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

I agree we need to do that refactor yes

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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@

import Foundation
import RevenueCat
#if canImport(SafariServices)
import SafariServices
#endif
import SwiftUI

#if PAYWALL_COMPONENTS
Expand All @@ -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) {
Expand Down Expand Up @@ -104,23 +101,6 @@ struct ButtonComponentView: View {

}

#if canImport(SafariServices)
private struct SafariView: UIViewControllerRepresentable {
let url: URL

func makeUIViewController(context: UIViewControllerRepresentableContext<SafariView>) -> SFSafariViewController {
SFSafariViewController(url: url)
}

func updateUIViewController(
_ uiViewController: SFSafariViewController,
context: UIViewControllerRepresentableContext<SafariView>
) {
// No updates needed
}
}
#endif

#if DEBUG

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
Expand Down
32 changes: 32 additions & 0 deletions RevenueCatUI/Views/SafariView.swift
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Lmk what you think of this. Seems to work fine and seemed like an easy solution :)

Copy link
Member

Choose a reason for hiding this comment

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

This is good yea! One thing though is that there's an existing (and pretty much identical) SafariView in ButtonComponentView.swift. This gives an "invalid redeclaration" error if PAYWALL_COMPONENTS is enabled.

The only difference between the 2 SafariViews is that the existing one uses

#if canImport(SafariServices)

and the new one uses

#if os(iOS)

Could we consolidate the 2?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ohh I had missed that one! Thanks for the heads up! I can try to consolidate yeah

let url: URL

func makeUIViewController(context: UIViewControllerRepresentableContext<SafariView>) -> SFSafariViewController {
return SFSafariViewController(url: url)
}

func updateUIViewController(_ uiViewController: SFSafariViewController,
context: UIViewControllerRepresentableContext<SafariView>) {
// No need to update the controller, as it's static in this case
}
}

#endif
44 changes: 42 additions & 2 deletions Sources/CustomerCenter/CustomerCenterConfigData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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) {
Expand All @@ -215,13 +222,33 @@ public struct CustomerCenterConfigData {
self = .changePlans
case "CANCEL":
self = .cancel
case "CUSTOM_URL":
self = .customUrl
default:
self = .unknown
}
}

}

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
}
}

}
Comment on lines +234 to +250
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if we should consolidate this with the existing PaywallComponent.ButtonComponent.URLMethod. On the one hand I'd say yes, because it is exactly the same concept we're describing so having 2 enums is confusing. On the other hand you could say that we're tying things together which might reduce flexibility.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm right kinda same thinking as you here... It's internal APIs so I guess we can go either way... I would suggest keeping it separate for flexibility for now, and we can always refactor later if needed. For example, we are currently only adding inApp and external for customer center but we have a deeplink option for paywalls... Having said that, I think we could try to refactor everything into a "link handler" to be used by both paywalls and customer center since it's close to the same thing


public struct PromotionalOffer {

public let iosOfferId: String
Expand Down Expand Up @@ -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) }
}

}
Expand Down Expand Up @@ -425,10 +452,23 @@ extension CustomerCenterConfigData.Localization {

extension CustomerCenterConfigData.HelpPath {

init(from response: CustomerCenterConfigResponse.HelpPath) {
init?(from response: CustomerCenterConfigResponse.HelpPath) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note that I changed the constructor to be nullable. If we get a custom url path without a url or open method, we can filter it out and this was the easiest way to achieve that

Copy link
Member

Choose a reason for hiding this comment

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

Would it be possible to make it throw instead? Imo absence of data (null) is not the same as incorrect input data. The latter is an error, the former doesn't have to be.

(I also find it odd that Foundation's URL constructor is nullable, for the same reason.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm so for customer center I would specially take a very "lax" approach. As in, if there is something misconfigured in the backend, I would prefer to try to display as much as possible instead of failing entirely. For example in this case, it basically means that if somehow a misconfiguration happened, we would just hide the path. I think we should do this since we're still changing the schema every now and then and we should be flexible.

Having said that, we could indeed throw in the constructor and handle it when creating the paths. The code becomes a bit less legible IMO, since we need to handle the catch though... I'm not sure TBH, since I know optional constructors are actually quite common in Swift (even if I don't like them as much 😅)... Wdyt @vegaro @aboedo ?

Copy link
Member

Choose a reason for hiding this comment

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

yeah, I think it also kinda maps better to what's happening underneath - you're using an optional constructor to try to build the URL in any case

Copy link
Member

@JayShortway JayShortway Oct 18, 2024

Choose a reason for hiding this comment

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

You're right, this is essentially validating JSON, which I agree should be lax. We could avoid handling the catch and just doing try?:

self.paths = response.paths.compactMap { try? CustomerCenterConfigData.HelpPath(from: $0) }

I don't feel super strongly about this btw, especially since optional constructors are common in Swift, as you said.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm yeah that's not bad. I also don't feel too strongly about this TBH... But considering we don't do that many throws along the codebase, I'm thinking on leaving it as is... If you feel we should better throw an error, I'm ok changing to your suggestion.

Copy link
Member

Choose a reason for hiding this comment

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

No it's minor imo. Feel free to leave as is!

Copy link
Contributor

Choose a reason for hiding this comment

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

I would also leave this as nullable as well because of the optional constructor it is using underneath

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 {
Expand Down
18 changes: 18 additions & 0 deletions Sources/Networking/Responses/CustomerCenterConfigResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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

}
Expand Down Expand Up @@ -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 {}
Expand Down Expand Up @@ -167,4 +179,10 @@ extension CustomerCenterConfigResponse.HelpPath.PathType: CodableEnumWithUnknown

}

extension CustomerCenterConfigResponse.HelpPath.OpenMethod: CodableEnumWithUnknownCase {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@vegaro Note that I added this for the new enum


static var unknownCase: Self { .unknown }

}

extension CustomerCenterConfigResponse: HTTPResponseBody {}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading