Skip to content

Commit a4d2b93

Browse files
authored
improve auto-tracking consumables on storekit2 observer mode, improve… (#131)
* improve auto-tracking consumables on storekit2 observer mode * Improve setAttribution method
1 parent 9dc4319 commit a4d2b93

9 files changed

+86
-35
lines changed

ApphudSDK.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Pod::Spec.new do |s|
22
s.name = 'ApphudSDK'
3-
s.version = '3.6.0'
3+
s.version = '3.6.2'
44
s.summary = 'Build and Measure In-App Subscriptions on iOS.'
55
s.description = 'Apphud covers every aspect when it comes to In-App Subscriptions from integration to analytics on iOS and Android.'
66
s.homepage = 'https://github.com/apphud/ApphudSDK'

Sources/Internal/ApphudAsyncStoreKit.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,20 @@ internal class ApphudAsyncStoreKit {
141141
}
142142
}
143143

144+
func fetchLatestTransaction() async -> StoreKit.Transaction? {
145+
var latestTransaction: StoreKit.Transaction?
146+
147+
for await result in StoreKit.Transaction.all {
148+
if case .verified(let transaction) = result {
149+
if latestTransaction == nil || latestTransaction!.purchaseDate < transaction.purchaseDate {
150+
latestTransaction = transaction
151+
}
152+
}
153+
}
154+
155+
return latestTransaction
156+
}
157+
144158
#if os(iOS) || os(tvOS) || os(macOS) || os(watchOS)
145159
@MainActor
146160
func purchase(product: Product, apphudProduct: ApphudProduct?, isPurchasing: Binding<Bool>? = nil) async -> ApphudAsyncPurchaseResult {

Sources/Internal/ApphudInternal+Attribution.swift

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ import Foundation
1111
extension ApphudInternal {
1212

1313
// MARK: - Attribution
14-
internal func setAttribution(data: ApphudAttributionData, from provider: ApphudAttributionProvider, identifer: String? = nil, callback: ((Bool) -> Void)?) {
14+
internal func setAttribution(data: ApphudAttributionData?, from provider: ApphudAttributionProvider, identifer: String? = nil, callback: ((Bool) -> Void)?) {
1515
performWhenUserRegistered {
1616
Task {
17-
var dict: [String: any Sendable] = data.rawData as? [String: any Sendable] ?? [:]
17+
var dict: [String: any Sendable] = data?.rawData as? [String: any Sendable] ?? [:]
1818

1919
switch provider {
2020
// ---------- .custom ----------
@@ -140,15 +140,17 @@ extension ApphudInternal {
140140
]
141141

142142
var attributionDict: [String: any Sendable] = [:]
143-
if let adNetwork = data.adNetwork { attributionDict["ad_network"] = adNetwork }
144-
if let channel = data.channel { attributionDict["channel"] = channel }
145-
if let campaign = data.campaign { attributionDict["campaign"] = campaign }
146-
if let adSet = data.adSet { attributionDict["ad_set"] = adSet }
147-
if let creative = data.creative { attributionDict["creative"] = creative }
148-
if let keyword = data.keyword { attributionDict["keyword"] = keyword }
149-
if let custom1 = data.custom1 { attributionDict["custom_1"] = custom1 }
150-
if let custom2 = data.custom2 { attributionDict["custom_2"] = custom2 }
151143

144+
if let data = data {
145+
if let adNetwork = data.adNetwork { attributionDict["ad_network"] = adNetwork }
146+
if let channel = data.channel { attributionDict["channel"] = channel }
147+
if let campaign = data.campaign { attributionDict["campaign"] = campaign }
148+
if let adSet = data.adSet { attributionDict["ad_set"] = adSet }
149+
if let creative = data.creative { attributionDict["creative"] = creative }
150+
if let keyword = data.keyword { attributionDict["keyword"] = keyword }
151+
if let custom1 = data.custom1 { attributionDict["custom_1"] = custom1 }
152+
if let custom2 = data.custom2 { attributionDict["custom_2"] = custom2 }
153+
}
152154
params["attribution"] = attributionDict
153155

154156
try? await Task.sleep(nanoseconds: 2_000_000_000)

Sources/Internal/ApphudInternal+Purchase.swift

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ extension ApphudInternal {
3535
internal func setNeedToCheckTransactions() {
3636
apphudPerformOnMainThread {
3737
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(self.checkTransactionsNow), object: nil)
38-
self.perform(#selector(self.checkTransactionsNow), with: nil, afterDelay: 3)
38+
self.perform(#selector(self.checkTransactionsNow), with: nil, afterDelay: 0.5)
3939
}
4040
}
4141

42-
@MainActor @objc private func checkTransactionsNow() {
42+
@MainActor @objc internal func checkTransactionsNow() {
4343

4444
if ApphudStoreKitWrapper.shared.isPurchasing {
4545
return
@@ -48,12 +48,10 @@ extension ApphudInternal {
4848
if #available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) {
4949

5050
if ApphudAsyncStoreKit.shared.isPurchasing { return }
51-
52-
Task {
53-
for await result in StoreKit.Transaction.currentEntitlements {
54-
if case .verified(let transaction) = result {
55-
await handleTransaction(transaction)
56-
}
51+
52+
Task(priority: .background) {
53+
if let latestTransaction = await ApphudAsyncStoreKit.shared.fetchLatestTransaction() {
54+
await handleTransaction(latestTransaction)
5755
}
5856
}
5957
}
@@ -68,8 +66,13 @@ extension ApphudInternal {
6866
let upgrade = transaction.isUpgraded
6967
let productID = transaction.productID
7068

69+
// use original transaction id to compare if already tracked
70+
if await isAlreadyTracked(transactionId: transaction.originalID, productId: productID, purchaseDate: purchaseDate) {
71+
apphudLog("This transaction already tracked by Apphud: \(transactionId), skipping", logLevel: .debug)
72+
return false
73+
}
74+
7175
let transactions = await self.lastUploadedTransactions
72-
7376
if transactions.contains(transactionId) {
7477
return false
7578
}
@@ -79,7 +82,7 @@ extension ApphudInternal {
7982
case .autoRenewable:
8083
isActive = expirationDate != nil && expirationDate! > Date() && refundDate == nil && upgrade == false
8184
default:
82-
isActive = purchaseDate > Date().addingTimeInterval(-86_400) && refundDate == nil
85+
isActive = refundDate == nil
8386
}
8487

8588
if isActive {
@@ -117,6 +120,22 @@ extension ApphudInternal {
117120
}
118121
return false
119122
}
123+
124+
fileprivate func isAlreadyTracked(transactionId: UInt64, productId: String, purchaseDate: Date) async -> Bool {
125+
126+
var trackedPurchases = await (ApphudInternal.shared.currentUser?.purchases ?? []).map { ($0.productId, $0.transactionId, $0.purchasedAt) }
127+
let trackedSubs = await (ApphudInternal.shared.currentUser?.subscriptions ?? []).map { ($0.productId, $0.originalTransactionId, $0.startedAt) }
128+
129+
trackedPurchases.append(contentsOf: trackedSubs)
130+
131+
for (pID, trxID, purchDate) in trackedPurchases {
132+
if pID == productId && (abs(purchDate.timeIntervalSince(purchaseDate)) < 2 || trxID == String(transactionId)) {
133+
return true
134+
}
135+
}
136+
137+
return false
138+
}
120139

121140
internal func appStoreReceipt() async -> String? {
122141
if let receiptString = apphudReceiptDataString() {

Sources/Internal/ApphudInternal.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,7 @@ final class ApphudInternal: NSObject {
526526
}
527527

528528
checkPendingRules()
529-
setNeedToCheckTransactions()
529+
checkTransactionsNow()
530530

531531
let minCheckInterval: Double = 60
532532
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {

Sources/Public/Apphud.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import Foundation
1414
import UserNotifications
1515
import SwiftUI
1616

17-
internal let apphud_sdk_version = "3.6.0"
17+
internal let apphud_sdk_version = "3.6.2"
1818

1919
// MARK: - Initialization
2020

@@ -778,14 +778,14 @@ final public class Apphud: NSObject {
778778
/**
779779
Submits attribution data to Apphud
780780

781-
- parameter data: Required. The ApphudAttributionData model.
781+
- parameter data: Optional. The ApphudAttributionData model. Pass nil for some integrations such as Apple Search Ads, Firebase.
782782
- parameter provider: Required. The name of the attribution provider.
783783
- parameter identifier: Optional. An identifier that matches between Apphud and the Attribution provider.
784784
- parameter callback: Optional. A closure that returns `true` if the data was successfully sent to Apphud.
785785

786786
- Note: Properly setting up attribution data is key for tracking and optimizing user acquisition strategies and measuring the ROI of marketing campaigns.
787787
*/
788-
public static func setAttribution(data: ApphudAttributionData, from provider: ApphudAttributionProvider, identifer: String? = nil, callback: ApphudBoolCallback?) {
788+
public static func setAttribution(data: ApphudAttributionData?, from provider: ApphudAttributionProvider, identifer: String? = nil, callback: ApphudBoolCallback?) {
789789
ApphudInternal.shared.setAttribution(data: data, from: provider, identifer: identifer, callback: callback)
790790
}
791791

Sources/Public/ApphudEnums.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public typealias ApphudBoolCallback = ((Bool) -> Void)
6868
}
6969

7070
internal enum ApphudIAPCodingKeys: String, CodingKey {
71-
case id, expiresAt, productId, cancelledAt, startedAt, inRetryBilling, autorenewEnabled, introductoryActivated, environment, local, groupId, status, kind
71+
case id, expiresAt, productId, cancelledAt, startedAt, inRetryBilling, autorenewEnabled, introductoryActivated, environment, local, groupId, status, kind, originalTransactionId, transactionId
7272
}
7373

7474
internal enum ApphudIAPKind: String {

Sources/Public/ApphudNonRenewingPurchase.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ public class ApphudNonRenewingPurchase: Codable {
3434
Returns `true` if purchase is made in test environment, i.e. sandbox or local purchase.
3535
*/
3636
public let isSandbox: Bool
37+
38+
/**
39+
Transaction identifier of the purchase. Can be null if decoding from cache during SDK upgrade.
40+
*/
41+
@objc public let transactionId: String?
3742

3843
/**
3944
Returns `true` if purchase was made using Local StoreKit Configuration File. Read more: https://docs.apphud.com/docs/testing-troubleshooting#local-storekit-testing
@@ -44,14 +49,14 @@ public class ApphudNonRenewingPurchase: Codable {
4449

4550
required public init(from decoder: Decoder) throws {
4651
let values = try decoder.container(keyedBy: ApphudIAPCodingKeys.self)
47-
(self.productId, self.canceledAt, self.purchasedAt, self.isSandbox, self.isLocal) = try Self.decodeValues(from: values)
52+
(self.productId, self.canceledAt, self.purchasedAt, self.isSandbox, self.isLocal, self.transactionId) = try Self.decodeValues(from: values)
4853
}
4954

5055
internal init(with values: KeyedDecodingContainer<ApphudIAPCodingKeys>) throws {
51-
(self.productId, self.canceledAt, self.purchasedAt, self.isSandbox, self.isLocal) = try Self.decodeValues(from: values)
56+
(self.productId, self.canceledAt, self.purchasedAt, self.isSandbox, self.isLocal, self.transactionId) = try Self.decodeValues(from: values)
5257
}
5358

54-
private static func decodeValues(from values: KeyedDecodingContainer<ApphudIAPCodingKeys>) throws -> (String, Date?, Date, Bool, Bool) {
59+
private static func decodeValues(from values: KeyedDecodingContainer<ApphudIAPCodingKeys>) throws -> (String, Date?, Date, Bool, Bool, String?) {
5560

5661
let kind = try values.decode(String.self, forKey: .kind)
5762

@@ -62,8 +67,9 @@ public class ApphudNonRenewingPurchase: Codable {
6267
let purchasedAt = try values.decode(String.self, forKey: .startedAt).apphudIsoDate ?? Date()
6368
let isSandbox = (try values.decode(String.self, forKey: .environment)) == ApphudEnvironment.sandbox.rawValue
6469
let isLocal = try values.decode(Bool.self, forKey: .local)
70+
let trxID = try? values.decode(String.self, forKey: .transactionId)
6571

66-
return (productId, canceledAt, purchasedAt, isSandbox, isLocal)
72+
return (productId, canceledAt, purchasedAt, isSandbox, isLocal, trxID)
6773
}
6874

6975
public func encode(to encoder: Encoder) throws {
@@ -74,6 +80,7 @@ public class ApphudNonRenewingPurchase: Codable {
7480
try container.encode(isSandbox ? ApphudEnvironment.sandbox.rawValue : ApphudEnvironment.production.rawValue, forKey: .environment)
7581
try container.encode(isLocal, forKey: .local)
7682
try container.encode(ApphudIAPKind.nonrenewable.rawValue, forKey: .kind)
83+
try? container.encode(transactionId, forKey: .transactionId)
7784
}
7885

7986
/**
@@ -92,6 +99,7 @@ public class ApphudNonRenewingPurchase: Codable {
9299
canceledAt = Date().addingTimeInterval(3600)
93100
isSandbox = apphudIsSandbox()
94101
isLocal = false
102+
transactionId = "0"
95103
}
96104

97105
internal var stateDescription: String {

Sources/Public/ApphudSubscription.swift

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ public class ApphudSubscription: Codable {
9898
False value means that user has canceled the subscription from App Store settings.
9999
*/
100100
@objc public let isAutorenewEnabled: Bool
101+
102+
/**
103+
Original transaction identifier of the subscription. Can be null if decoding from cache during SDK upgrade.
104+
*/
105+
@objc public let originalTransactionId: String?
101106

102107
/**
103108
True value means that user has already used introductory offer for this subscription (free trial, pay as you go or pay up front).
@@ -116,14 +121,14 @@ public class ApphudSubscription: Codable {
116121

117122
required public init(from decoder: Decoder) throws {
118123
let values = try decoder.container(keyedBy: ApphudIAPCodingKeys.self)
119-
(self.id, self.expiresDate, self.productId, self.canceledAt, self.startedAt, self.isInRetryBilling, self.isAutorenewEnabled, self.isIntroductoryActivated, self.isSandbox, self.isLocal, self.groupId, self.status) = try Self.decodeValues(from: values)
124+
(self.id, self.expiresDate, self.productId, self.canceledAt, self.startedAt, self.isInRetryBilling, self.isAutorenewEnabled, self.isIntroductoryActivated, self.isSandbox, self.isLocal, self.groupId, self.status, self.originalTransactionId) = try Self.decodeValues(from: values)
120125
}
121126

122127
internal init(with values: KeyedDecodingContainer<ApphudIAPCodingKeys>) throws {
123-
(self.id, self.expiresDate, self.productId, self.canceledAt, self.startedAt, self.isInRetryBilling, self.isAutorenewEnabled, self.isIntroductoryActivated, self.isSandbox, self.isLocal, self.groupId, self.status) = try Self.decodeValues(from: values)
128+
(self.id, self.expiresDate, self.productId, self.canceledAt, self.startedAt, self.isInRetryBilling, self.isAutorenewEnabled, self.isIntroductoryActivated, self.isSandbox, self.isLocal, self.groupId, self.status, self.originalTransactionId) = try Self.decodeValues(from: values)
124129
}
125130

126-
private static func decodeValues(from values: KeyedDecodingContainer<ApphudIAPCodingKeys>) throws -> (String, Date, String, Date?, Date, Bool, Bool, Bool, Bool, Bool, String, ApphudSubscriptionStatus) {
131+
private static func decodeValues(from values: KeyedDecodingContainer<ApphudIAPCodingKeys>) throws -> (String, Date, String, Date?, Date, Bool, Bool, Bool, Bool, Bool, String, ApphudSubscriptionStatus, String?) {
127132

128133
let expiresDateString = try values.decode(String.self, forKey: .expiresAt)
129134
guard let expDate = expiresDateString.apphudIsoDate else { throw ApphudError(message: "Missing Expires Date") }
@@ -140,8 +145,9 @@ public class ApphudSubscription: Codable {
140145
let isLocal = try values.decode(Bool.self, forKey: .local)
141146
let groupId = try values.decode(String.self, forKey: .groupId)
142147
let status = try values.decode(ApphudSubscriptionStatus.self, forKey: .status)
143-
144-
return (id, expiresDate, productId, canceledAt, startedAt, isInRetryBilling, isAutorenewEnabled, isIntroductoryActivated, isSandbox, isLocal, groupId, status)
148+
let origID = try? values.decode(String.self, forKey: .originalTransactionId)
149+
150+
return (id, expiresDate, productId, canceledAt, startedAt, isInRetryBilling, isAutorenewEnabled, isIntroductoryActivated, isSandbox, isLocal, groupId, status, origID)
145151
}
146152

147153
public func encode(to encoder: Encoder) throws {
@@ -159,6 +165,7 @@ public class ApphudSubscription: Codable {
159165
try container.encode(groupId, forKey: .groupId)
160166
try container.encode(status.rawValue, forKey: .status)
161167
try container.encode(ApphudIAPKind.autorenewable.rawValue, forKey: .kind)
168+
try? container.encode(originalTransactionId, forKey: .originalTransactionId)
162169
}
163170

164171
internal init(product: SKProduct) {
@@ -174,6 +181,7 @@ public class ApphudSubscription: Codable {
174181
groupId = stub_key
175182
status = product.apphudIsTrial ? .trial : product.apphudIsPaidIntro ? .intro : .regular
176183
isIntroductoryActivated = status == .trial || status == .intro
184+
originalTransactionId = nil
177185
}
178186

179187
internal var stateDescription: String {

0 commit comments

Comments
 (0)