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

Paywalls: Open up paywalls from deeplinks #4285

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -44,6 +44,7 @@
2D4D6AF624F7193700B656BE /* verifyReceiptSample1.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2DDE559A24C8B5E300DCB087 /* verifyReceiptSample1.txt */; };
2D4D6AF724F7193700B656BE /* base64encodedreceiptsample1.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2DDE559B24C8B5E300DCB087 /* base64encodedreceiptsample1.txt */; };
2D4E926526990AB1000E10B0 /* StoreKit1Wrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4E926426990AB1000E10B0 /* StoreKit1Wrapper.swift */; };
2D6AA7B22C99D11A001DD27A /* RevenueCatDeeplinkHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D6AA7B12C99D11A001DD27A /* RevenueCatDeeplinkHandler.swift */; };
2D735F7E26EFF198004E82A7 /* UnitTestsConfiguration.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 2D43017726EBFD7100BAB891 /* UnitTestsConfiguration.storekit */; };
2D803F6326F144830069D717 /* Nimble in Frameworks */ = {isa = PBXBuildFile; productRef = 2D803F6226F144830069D717 /* Nimble */; };
2D803F6626F144BF0069D717 /* Nimble in Frameworks */ = {isa = PBXBuildFile; productRef = 2D803F6526F144BF0069D717 /* Nimble */; };
Expand Down Expand Up @@ -1168,6 +1169,7 @@
2D4E926426990AB1000E10B0 /* StoreKit1Wrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKit1Wrapper.swift; sourceTree = "<group>"; };
2D5BB46A24C8E8ED00E27537 /* PurchasesReceiptParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasesReceiptParser.swift; sourceTree = "<group>"; };
2D69384426DFF93300FCDBC0 /* StoreProductTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreProductTests.swift; sourceTree = "<group>"; };
2D6AA7B12C99D11A001DD27A /* RevenueCatDeeplinkHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevenueCatDeeplinkHandler.swift; sourceTree = "<group>"; };
2D84458826B9CD270033B5A3 /* ReceiptFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptFetcherTests.swift; sourceTree = "<group>"; };
2D8D03B42799A2B90044C2ED /* DocCDocumentation.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = DocCDocumentation.docc; sourceTree = "<group>"; };
2D8DB34A24072AAE00BE3D31 /* SubscriberAttributeTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriberAttributeTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4083,6 +4085,7 @@
887A605D2C1D037000E1A461 /* RemoteImage.swift */,
887A605E2C1D037000E1A461 /* TemplateBackgroundImageView.swift */,
88A543E62C37A4C40039C6A5 /* TierSelectorView.swift */,
2D6AA7B12C99D11A001DD27A /* RevenueCatDeeplinkHandler.swift */,
);
path = Views;
sourceTree = "<group>";
Expand Down Expand Up @@ -5974,6 +5977,7 @@
353756682C382C2800A1B8D6 /* CustomerCenterViewModel.swift in Sources */,
887A60BD2C1D037000E1A461 /* TemplateViewType.swift in Sources */,
887A606D2C1D037000E1A461 /* Localization.swift in Sources */,
2D6AA7B22C99D11A001DD27A /* RevenueCatDeeplinkHandler.swift in Sources */,
887A60CA2C1D037000E1A461 /* RemoteImage.swift in Sources */,
88B1BAF42C813A3C001B7EE5 /* SpacerComponentViewModel.swift in Sources */,
887A607B2C1D037000E1A461 /* Bundle+Extensions.swift in Sources */,
Expand Down
107 changes: 107 additions & 0 deletions RevenueCatUI/Views/RevenueCatDeeplinkHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//
// 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
//
// RevenueCatDeeplinkHandler.swift
//
// Created by Andrés Boedo on 9/17/24.

import Foundation
import RevenueCat
import SwiftUI

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable, message: "RevenueCatUI does not support macOS yet")
@available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet")
struct RevenueCatDeeplinkHandlerView<Content: View>: View {
Copy link
Member Author

Choose a reason for hiding this comment

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

to get this through the finish line, we really need to:

  • settle on the exact format. I used /paywall in the examples but I'm not even enforcing it, maybe revenuecatui is more appropriate?
  • clean up error handling and logging
  • add tests

@State private var isShowingPaywall: Bool = false
@State private var offeringID: String?

let content: Content

init(@ViewBuilder content: () -> Content) {
self.content = content()
}

var body: some View {
content
.onOpenURL { url in
Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure if we would like to also support UIKit apps... but I guess that can come in a separate PR.

Copy link
Member Author

Choose a reason for hiding this comment

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

yeah, I figured for UIKit we'd basically do the appDelegate + present from root view controller path

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I think just adding a method that can be called by a developer in the app delegate method would work 👍

if let extractedOfferingID = extractOfferingID(from: url) {
tonidero marked this conversation as resolved.
Show resolved Hide resolved
offeringID = extractedOfferingID
isShowingPaywall = true
}
}
.sheet(
isPresented: $isShowingPaywall,
onDismiss: {
offeringID = nil
},
content: {
if let offeringID = offeringID {
OfferingLoaderView(offeringID: offeringID)
} else {
Text("Invalid offering ID.")
}
})
}

private func extractOfferingID(from url: URL) -> String? {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
return components?.queryItems?.first(where: { $0.name == "offeringID" })?.value
}
}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable, message: "RevenueCatUI does not support macOS yet")
@available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet")
extension View {
func handleRevenueCatDeeplinks() -> some View {
RevenueCatDeeplinkHandlerView {
self
}
}
}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable, message: "RevenueCatUI does not support macOS yet")
@available(tvOS, unavailable, message: "RevenueCatUI does not support tvOS yet")
struct OfferingLoaderView: View {
let offeringID: String
@State private var offering: Offering?
@State private var isLoading = true

var body: some View {
Group {
if let offering = offering {
PaywallView(offering: offering)
Copy link
Contributor

Choose a reason for hiding this comment

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

Thinking about it, we might also want to offer a way to not display the paywall if the user has an entitlement (passing a requiredEntitlementIdentifier). But this could also be a separate PR I guess.

Copy link
Member Author

Choose a reason for hiding this comment

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

yeah, that's a good point. It could be done as an extra query param in the deeplink too

} else if isLoading {
ProgressView()
} else {
Text("Could not load offering.")
}
}
.task {
await fetchOffering()
}
}

private func fetchOffering() async {
do {
let offerings = try await Purchases.shared.offerings()
if let offering = offerings.offering(identifier: offeringID) {
self.offering = offering
} else {
isLoading = false
print("Offering with ID \(offeringID) not found.")
}
} catch {
isLoading = false
print("Error fetching offering: \(error.localizedDescription)")
}
}
}
13 changes: 13 additions & 0 deletions Tests/TestingApps/PaywallsTester/PaywallsTester/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>com.revenuecat.PaywallsTester</string>
<key>CFBundleURLSchemes</key>
<array>
<string>paywallsTester</string>
</array>
</dict>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>
Expand Down
27 changes: 23 additions & 4 deletions Tests/TestingApps/PaywallsTester/PaywallsTester/Products.storekit
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
{
"appPolicies" : {
"eula" : "",
"policies" : [
{
"locale" : "en_US",
"policyText" : "",
"policyURL" : ""
}
]
},
Copy link
Member Author

Choose a reason for hiding this comment

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

oh interesting, the stuff in this file just gets updated automatically when you open up Xcode 16. Will revert since it's not actually part of the PR

"identifier" : "882C6E98",
"nonRenewingSubscriptions" : [

Expand Down Expand Up @@ -132,7 +142,10 @@
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Monthly",
"subscriptionGroupID" : "CEEF018E",
"type" : "RecurringSubscription"
"type" : "RecurringSubscription",
"winbackOffers" : [

]
},
{
"adHocOffers" : [
Expand Down Expand Up @@ -167,7 +180,10 @@
"recurringSubscriptionPeriod" : "P1Y",
"referenceName" : "Yearly",
"subscriptionGroupID" : "CEEF018E",
"type" : "RecurringSubscription"
"type" : "RecurringSubscription",
"winbackOffers" : [

]
},
{
"adHocOffers" : [
Expand All @@ -192,13 +208,16 @@
"recurringSubscriptionPeriod" : "P1W",
"referenceName" : "Weekly",
"subscriptionGroupID" : "CEEF018E",
"type" : "RecurringSubscription"
"type" : "RecurringSubscription",
"winbackOffers" : [

]
}
]
}
],
"version" : {
"major" : 3,
"major" : 4,
"minor" : 0
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ struct AppContentView: View {
}
#endif
}
.handleRevenueCatDeeplinks()
Copy link
Contributor

Choose a reason for hiding this comment

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

Nothing to do here but I must say, I'm still not used to having these things as modifiers of a view, seems like a very weird API to me 😅

Copy link
Member Author

Choose a reason for hiding this comment

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

🤔 what would a more natural API look like to you as a dev? I'm open to anything as long as its easy to do. I know others do the setup in the app delegate, but the industry seems to be moving away from that

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I think this is the way it's supposed to be in SwiftUI (like the onOpenURL API we're using). Just seems very weird to tie this behavior to a view IMO... So yeah, I think this is the most SwiftUI solution for now.

Copy link
Member

Choose a reason for hiding this comment

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

Yes I feel you Toni haha, but indeed this seems to be the SwiftUI way. In Compose it would be more natural to add a route to a navigation graph. The downside of that is that there are multiple ways to build such a graph, as mentioned on Slack.

}

private var background: some View {
Expand Down