Skip to content

Commit 7f1e0fa

Browse files
committed
Added subscription logic
by using the local in-app purchase receipt
1 parent 092406b commit 7f1e0fa

22 files changed

+584
-117
lines changed

Cryptomator.xcodeproj/project.pbxproj

+28
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
4A13612D276768000077EB7F /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A13612C276768000077EB7F /* SnapshotHelper.swift */; };
2323
4A13612F27676F5C0077EB7F /* SnapshotCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A13612E27676F5C0077EB7F /* SnapshotCoordinator.swift */; };
2424
4A136132276770BB0077EB7F /* SnapshotVaultListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A136131276770BB0077EB7F /* SnapshotVaultListViewModel.swift */; };
25+
4A1521E427C55EA2006C96B2 /* TPInAppReceipt in Frameworks */ = {isa = PBXBuildFile; productRef = 4A1521E327C55EA2006C96B2 /* TPInAppReceipt */; };
2526
4A1673E1270C43AF0075C724 /* LoadingWithLabelCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1673E0270C43AF0075C724 /* LoadingWithLabelCell.swift */; };
2627
4A1673E3270C4DD90075C724 /* LoadingWithLabelCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1673E2270C4DD90075C724 /* LoadingWithLabelCellViewModel.swift */; };
2728
4A1673E6270C652A0075C724 /* FileProviderCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1673E4270C5E6C0075C724 /* FileProviderCacheManager.swift */; };
@@ -240,6 +241,8 @@
240241
4AB8539E26BA8B4C00555F00 /* VaultDetailUnlockCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AB8539D26BA8B4C00555F00 /* VaultDetailUnlockCoordinator.swift */; };
241242
4ABC08D7250D1EB600E3CEDC /* DeletionTaskManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ABC08D6250D1EB600E3CEDC /* DeletionTaskManagerTests.swift */; };
242243
4ABCF3522726D24800A7FBB7 /* MoveVaultViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ABCF3512726D24800A7FBB7 /* MoveVaultViewModelTests.swift */; };
244+
4AC005F127C3D80B006FFE87 /* PremiumManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AC005F027C3D80B006FFE87 /* PremiumManager.swift */; };
245+
4AC005F327C3D932006FFE87 /* PremiumManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AC005F227C3D932006FFE87 /* PremiumManagerMock.swift */; };
243246
4AC86270273598CC00E15BA5 /* UIViewController+ProgressHUDError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AC8626F273598CC00E15BA5 /* UIViewController+ProgressHUDError.swift */; };
244247
4AD0F61C24AF203F0026B765 /* FileProvider+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD0F61B24AF203F0026B765 /* FileProvider+Actions.swift */; };
245248
4ADBD35827284BAB00B19B5C /* MoveVaultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ADBD35727284BAB00B19B5C /* MoveVaultViewController.swift */; };
@@ -698,6 +701,8 @@
698701
4AB8539D26BA8B4C00555F00 /* VaultDetailUnlockCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultDetailUnlockCoordinator.swift; sourceTree = "<group>"; };
699702
4ABC08D6250D1EB600E3CEDC /* DeletionTaskManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletionTaskManagerTests.swift; sourceTree = "<group>"; };
700703
4ABCF3512726D24800A7FBB7 /* MoveVaultViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveVaultViewModelTests.swift; sourceTree = "<group>"; };
704+
4AC005F027C3D80B006FFE87 /* PremiumManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumManager.swift; sourceTree = "<group>"; };
705+
4AC005F227C3D932006FFE87 /* PremiumManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumManagerMock.swift; sourceTree = "<group>"; };
701706
4AC8626F273598CC00E15BA5 /* UIViewController+ProgressHUDError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+ProgressHUDError.swift"; sourceTree = "<group>"; };
702707
4AD0F61B24AF203F0026B765 /* FileProvider+Actions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileProvider+Actions.swift"; sourceTree = "<group>"; };
703708
4ADBD35727284BAB00B19B5C /* MoveVaultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveVaultViewController.swift; sourceTree = "<group>"; };
@@ -883,6 +888,7 @@
883888
files = (
884889
4A9172822619F17C003C4043 /* CryptomatorCommon in Frameworks */,
885890
4A1673ED270DE4600075C724 /* libCryptomatorFileProvider.a in Frameworks */,
891+
4A1521E427C55EA2006C96B2 /* TPInAppReceipt in Frameworks */,
886892
);
887893
runOnlyForDeploymentPostprocessing = 0;
888894
};
@@ -1455,6 +1461,7 @@
14551461
4A1C6D632750EED900B41FFF /* IAPManagerMock.swift */,
14561462
4A1DB290275FA4AE00A5F27B /* IAPStoreMock.swift */,
14571463
4A3C5DD3272AF98700EB7C7A /* MaintenanceManagerMock.swift */,
1464+
4AC005F227C3D932006FFE87 /* PremiumManagerMock.swift */,
14581465
);
14591466
path = Mocks;
14601467
sourceTree = "<group>";
@@ -1699,6 +1706,7 @@
16991706
74F5DC1A26DCD2E300AFE989 /* Purchase */ = {
17001707
isa = PBXGroup;
17011708
children = (
1709+
4AC005F027C3D80B006FFE87 /* PremiumManager.swift */,
17021710
4A4246F1275640C9005BE82D /* IAPViewController.swift */,
17031711
4A5AC440275A5B3500342AA7 /* PurchaseAlert.swift */,
17041712
74C2BC5126E8FCD000BCAA03 /* PurchaseCoordinator.swift */,
@@ -1835,6 +1843,7 @@
18351843
name = Cryptomator;
18361844
packageProductDependencies = (
18371845
4A9172812619F17C003C4043 /* CryptomatorCommon */,
1846+
4A1521E327C55EA2006C96B2 /* TPInAppReceipt */,
18381847
);
18391848
productName = Cryptomator;
18401849
productReference = 4AE97DA824572E4900452814 /* Cryptomator.app */;
@@ -1952,6 +1961,7 @@
19521961
);
19531962
mainGroup = 4A5E5B202453119100BD6298;
19541963
packageReferences = (
1964+
4A1521E227C55EA2006C96B2 /* XCRemoteSwiftPackageReference "TPInAppReceipt" */,
19551965
);
19561966
productRefGroup = 4A5E5B2A2453119100BD6298 /* Products */;
19571967
projectDirPath = "";
@@ -2238,6 +2248,7 @@
22382248
4A4246F2275640C9005BE82D /* IAPViewController.swift in Sources */,
22392249
4A53CC17267CDBFF00853BB3 /* CreateNewVaultChooseFolderViewModel.swift in Sources */,
22402250
4A6A521D268B7C8F006F7368 /* BaseNavigationController.swift in Sources */,
2251+
4AC005F127C3D80B006FFE87 /* PremiumManager.swift in Sources */,
22412252
4ADD2342267383BE00374E4E /* AddVaultSuccessViewModel.swift in Sources */,
22422253
4A79E26926B16993008C9959 /* ActionButton.swift in Sources */,
22432254
4AF91CD925A722A600ACF01E /* VaultInfo.swift in Sources */,
@@ -2411,6 +2422,7 @@
24112422
4A644B49267B40C3008CBB9A /* SetVaultNameViewModelTests.swift in Sources */,
24122423
4AEFF7F627145F5A00D6CB99 /* FileProviderConnectorMock.swift in Sources */,
24132424
4AFCE56A25BAEE890069C4FC /* AccountListViewModelTests.swift in Sources */,
2425+
4AC005F327C3D932006FFE87 /* PremiumManagerMock.swift in Sources */,
24142426
4ABCF3522726D24800A7FBB7 /* MoveVaultViewModelTests.swift in Sources */,
24152427
4A644B59267CA3AD008CBB9A /* CreateNewFolderViewModelTests.swift in Sources */,
24162428
4A707804278DC37F00AEF4CE /* VaultKeepUnlockedViewModelTests.swift in Sources */,
@@ -3093,7 +3105,23 @@
30933105
};
30943106
/* End XCConfigurationList section */
30953107

3108+
/* Begin XCRemoteSwiftPackageReference section */
3109+
4A1521E227C55EA2006C96B2 /* XCRemoteSwiftPackageReference "TPInAppReceipt" */ = {
3110+
isa = XCRemoteSwiftPackageReference;
3111+
repositoryURL = "https://github.com/tikhop/TPInAppReceipt.git";
3112+
requirement = {
3113+
kind = upToNextMinorVersion;
3114+
minimumVersion = 3.3.0;
3115+
};
3116+
};
3117+
/* End XCRemoteSwiftPackageReference section */
3118+
30963119
/* Begin XCSwiftPackageProductDependency section */
3120+
4A1521E327C55EA2006C96B2 /* TPInAppReceipt */ = {
3121+
isa = XCSwiftPackageProductDependency;
3122+
package = 4A1521E227C55EA2006C96B2 /* XCRemoteSwiftPackageReference "TPInAppReceipt" */;
3123+
productName = TPInAppReceipt;
3124+
};
30973125
4A9172712619F16C003C4043 /* CryptomatorCommonCore */ = {
30983126
isa = XCSwiftPackageProductDependency;
30993127
productName = CryptomatorCommonCore;

Cryptomator.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

+18
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@
1010
"version": "1.4.0"
1111
}
1212
},
13+
{
14+
"package": "ASN1Swift",
15+
"repositoryURL": "https://github.com/tikhop/ASN1Swift",
16+
"state": {
17+
"branch": null,
18+
"revision": "b53bee03a942623db25afc5bfb80227b2cb3b425",
19+
"version": "1.2.4"
20+
}
21+
},
1322
{
1423
"package": "Base32",
1524
"repositoryURL": "https://github.com/norio-nomura/Base32.git",
@@ -144,6 +153,15 @@
144153
"revision": "5d66f7ba25daf4f94100e7022febf3c75e37a6c7",
145154
"version": "1.4.2"
146155
}
156+
},
157+
{
158+
"package": "TPInAppReceipt",
159+
"repositoryURL": "https://github.com/tikhop/TPInAppReceipt.git",
160+
"state": {
161+
"branch": null,
162+
"revision": "2b9946f8fe2dd74ed87dfcb5dbedc3be06c5ccab",
163+
"version": "3.3.3"
164+
}
147165
}
148166
]
149167
},

Cryptomator/AppDelegate.swift

+4
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
106106
return false
107107
}
108108

109+
func applicationDidBecomeActive(_ application: UIApplication) {
110+
PremiumManager.shared.refreshStatus()
111+
}
112+
109113
func applicationWillTerminate(_ application: UIApplication) {
110114
SKPaymentQueue.default().remove(StoreObserver.shared)
111115
}

Cryptomator/MainCoordinator.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ extension MainCoordinator: RemoveVaultDelegate {
112112
extension MainCoordinator: StoreObserverDelegate {
113113
func purchaseDidSucceed(transaction: PurchaseTransaction) {
114114
switch transaction {
115-
case .fullVersion:
115+
case .fullVersion, .yearlySubscription:
116116
showFullVersionAlert()
117117
case let .freeTrial(expiresOn):
118118
showTrialAlert(expirationDate: expiresOn)
+156
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
//
2+
// PremiumManager.swift
3+
// Cryptomator
4+
//
5+
// Created by Philipp Schmid on 09.02.22.
6+
// Copyright © 2022 Skymatic GmbH. All rights reserved.
7+
//
8+
9+
import CocoaLumberjackSwift
10+
import CryptomatorCommonCore
11+
import Foundation
12+
import Promises
13+
import StoreKit
14+
import TPInAppReceipt
15+
16+
protocol PremiumManagerType {
17+
/**
18+
Updates the premium status by reading the local app store receipt.
19+
20+
The local app store receipt can only be read by the main app.
21+
*/
22+
func refreshStatus()
23+
/**
24+
Returns the expiration date of the trial for a given purchase date by adding 30 days.
25+
26+
- Note: To obtain the correct expiration date of a trial, the original purchase date should be passed. Otherwise, a restored transaction could extend the trial period.
27+
- Returns: The trial expiration date, or nil if a date could not be calculated with the given input
28+
*/
29+
func trialExpirationDate(for purchaseDate: Date) -> Date?
30+
}
31+
32+
class PremiumManager: PremiumManagerType {
33+
static let shared = PremiumManager(cryptomatorSettings: CryptomatorUserDefaults.shared)
34+
private var cryptomatorSettings: CryptomatorSettings
35+
36+
init(cryptomatorSettings: CryptomatorSettings) {
37+
self.cryptomatorSettings = cryptomatorSettings
38+
refreshStatus()
39+
}
40+
41+
func refreshStatus() {
42+
reloadReceipt()
43+
}
44+
45+
func trialExpirationDate(for purchaseDate: Date) -> Date? {
46+
return Calendar.current.date(byAdding: .day, value: 30, to: purchaseDate)
47+
}
48+
49+
private func reloadReceipt() {
50+
let receipt: InAppReceipt
51+
do {
52+
receipt = try InAppReceipt.localReceipt()
53+
} catch {
54+
DDLogError("PremiumManager.reloadReceipt failed with error: \(error)")
55+
return
56+
}
57+
let premiumHistory = createPremiumHistory(for: receipt)
58+
savePremiumHistory(premiumHistory)
59+
}
60+
61+
private func savePremiumHistory(_ premiumHistory: PremiumHistory) {
62+
cryptomatorSettings.trialExpirationDate = premiumHistory.trialExpirationDate
63+
cryptomatorSettings.fullVersionUnlocked = premiumHistory.lifetimePremiumEnabled
64+
cryptomatorSettings.hasRunningSubscription = premiumHistory.hasRunningSubscription
65+
}
66+
67+
private func createPremiumHistory(for receipt: InAppReceipt) -> PremiumHistory {
68+
return PremiumHistory(trialExpirationDate: getTrialExpirationDate(for: receipt),
69+
hasRunningSubscription: hasRunningSubscription(receipt: receipt),
70+
lifetimePremiumEnabled: hasLifetimePremium(receipt: receipt))
71+
}
72+
73+
private func getActiveAutoRenewableSubscriptionExpirationDate(receipt: InAppReceipt) -> Date? {
74+
let activeAutoRenewableSubscription = receipt.activeAutoRenewableSubscriptionPurchases(ofProductIdentifier: .yearlySubscription, forDate: Date())
75+
return activeAutoRenewableSubscription?.subscriptionExpirationDate
76+
}
77+
78+
private func hasRunningSubscription(receipt: InAppReceipt) -> Bool {
79+
return receipt.hasActiveAutoRenewableSubscription(ofProductIdentifier: .yearlySubscription, forDate: Date())
80+
}
81+
82+
private func hasLifetimePremium(receipt: InAppReceipt) -> Bool {
83+
let freeUpgradePurchases = receipt.validPurchases(ofProductIdentifier: .freeUpgrade)
84+
let paidUpgradePurchases = receipt.validPurchases(ofProductIdentifier: .paidUpgrade)
85+
let fullVersionPurchases = receipt.validPurchases(ofProductIdentifier: .fullVersion)
86+
87+
var premiumPurchases = [InAppPurchase]()
88+
premiumPurchases.append(contentsOf: freeUpgradePurchases)
89+
premiumPurchases.append(contentsOf: paidUpgradePurchases)
90+
premiumPurchases.append(contentsOf: fullVersionPurchases)
91+
return !premiumPurchases.isEmpty
92+
}
93+
94+
private func hasRunningTrial(receipt: InAppReceipt) -> Bool {
95+
let trialPurchases = receipt.validPurchases(ofProductIdentifier: .thirtyDayTrial)
96+
for purchase in trialPurchases {
97+
guard let trialExpirationDate = trialExpirationDate(for: purchase) else {
98+
continue
99+
}
100+
if trialExpirationDate > Date() {
101+
return true
102+
}
103+
}
104+
return false
105+
}
106+
107+
private func getTrialExpirationDate(for receipt: InAppReceipt) -> Date? {
108+
let trialPurchases = receipt.validPurchases(ofProductIdentifier: .thirtyDayTrial)
109+
let trialExpirationDates = trialPurchases.map { trialExpirationDate(for: $0) ?? .distantPast }
110+
let descendingSortedTrialExpirationDates = trialExpirationDates.sorted(by: { $0 > $1 })
111+
return descendingSortedTrialExpirationDates.first
112+
}
113+
114+
private func trialExpirationDate(for purchase: InAppPurchase) -> Date? {
115+
let purchaseDate: Date
116+
if let originalPurchaseDate = purchase.originalPurchaseDate {
117+
purchaseDate = originalPurchaseDate
118+
} else {
119+
purchaseDate = purchase.purchaseDate
120+
}
121+
return trialExpirationDate(for: purchaseDate)
122+
}
123+
}
124+
125+
private struct PremiumHistory: Codable {
126+
let trialExpirationDate: Date?
127+
let hasRunningSubscription: Bool
128+
let lifetimePremiumEnabled: Bool
129+
}
130+
131+
extension InAppReceipt {
132+
/**
133+
Returns all valid purchases of a given product identifier by filtering out cancelled purchases.
134+
135+
A cancellation date that is not nil means that the purchase has been refunded by Apple Support or the user has upgraded to a higher subscription plan.
136+
Subscriptions canceled by the user but possibly not yet expired will continue to be returned.
137+
*/
138+
func validPurchases(ofProductIdentifier productIdentifier: ProductIdentifier) -> [InAppPurchase] {
139+
return purchases(ofProductIdentifier: productIdentifier).filter { $0.cancellationDate == nil }
140+
}
141+
142+
// MARK: Convenience
143+
144+
func purchases(ofProductIdentifier productIdentifier: ProductIdentifier,
145+
sortedBy sort: ((InAppPurchase, InAppPurchase) -> Bool)? = nil) -> [InAppPurchase] {
146+
return purchases(ofProductIdentifier: productIdentifier.rawValue, sortedBy: sort)
147+
}
148+
149+
func hasActiveAutoRenewableSubscription(ofProductIdentifier productIdentifier: ProductIdentifier, forDate date: Date) -> Bool {
150+
return hasActiveAutoRenewableSubscription(ofProductIdentifier: productIdentifier.rawValue, forDate: date)
151+
}
152+
153+
func activeAutoRenewableSubscriptionPurchases(ofProductIdentifier productIdentifier: ProductIdentifier, forDate date: Date) -> InAppPurchase? {
154+
return activeAutoRenewableSubscriptionPurchases(ofProductIdentifier: productIdentifier.rawValue, forDate: date)
155+
}
156+
}

Cryptomator/Purchase/PurchaseViewController.swift

+10
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ class PurchaseViewController: IAPViewController<PurchaseSection, PurchaseButtonA
5656
viewModel.replaceRetrySectionWithLoadingSection()
5757
applySnapshot(sections: viewModel.sections)
5858
fetchProducts()
59+
case .redeemCode:
60+
if #available(iOS 14.0, *) {
61+
viewModel.redeemCode()
62+
}
63+
case .startSubscription:
64+
viewModel.startSubscription().then { [weak self] in
65+
self?.coordinator?.fullVersionPurchased()
66+
}.catch { [weak self] error in
67+
self?.handleError(error)
68+
}
5969
case .unknown, .none:
6070
break
6171
}

0 commit comments

Comments
 (0)