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

AdServices #1727

Merged
merged 25 commits into from
Aug 8, 2022
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fd2fb2d
[CF-106] Fetch AdServices Token (#1519)
May 2, 2022
6879358
Merge branch 'main' into ad-services-sdk
May 10, 2022
d14960f
Merge branch 'main' into ad-services-sdk
May 31, 2022
e1021cb
Merge branch 'main' into ad-services-sdk
Jun 7, 2022
cbe9819
[CF-553] Post AdServices token (#1534)
Jun 9, 2022
cbc44cb
Merged in main
joshdholtz Jun 22, 2022
f5ed569
Update ios 14 snapshots tets
joshdholtz Jun 22, 2022
c4b6440
Reworked adservices to use new Purchases.shared.attribution API
joshdholtz Jun 22, 2022
efa22df
Updated with some comments from PR
joshdholtz Jun 23, 2022
a3e6429
Merged main
joshdholtz Jun 23, 2022
e18b65e
Merge branch 'main' into ad-services-sdk
Jun 25, 2022
45ace53
Add iAd code back (#1739)
Jun 28, 2022
64740f7
Merge branch 'main' into ad-services-sdk
Jun 28, 2022
4f99f74
use new mapKeys dictionary extension for device cache
Jun 28, 2022
07efb51
update for some pr comments
Jun 28, 2022
4b48fa4
remove purchasestests
Jun 28, 2022
30135d6
generate missing snapshots
Jun 28, 2022
a511a74
fix deprecation messages to clarify method now called on shared instance
Jun 28, 2022
3cee7b5
Fix for inserting nonstring into userdefaults
Jun 29, 2022
760a4c1
Add tests for bug
Jun 29, 2022
cdb8df6
fix lint
Jun 30, 2022
9b3657f
Merge tag '4.7.0' into ad-services-sdk
joshdholtz Jul 7, 2022
7af4813
Merge remote-tracking branch 'origin/main' into ad-services-sdk
joshdholtz Aug 2, 2022
d33d501
Added availablility checks for tests and removed unused varialbes
joshdholtz Aug 2, 2022
04363d7
Whoops... wrong class
joshdholtz Aug 2, 2022
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
46 changes: 29 additions & 17 deletions RevenueCat.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions Sources/Attribution/AttributionFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,16 @@ import UIKit
import WatchKit
#endif

#if canImport(AdServices)
import AdServices
#endif

enum AttributionFetcherError: Error {

case identifierForAdvertiserUnavailableForPlatform
case identifierForAdvertiserFrameworksUnavailable
case adServicesNotAvailable
case adServicesTokenFetchError

}

Expand Down Expand Up @@ -82,6 +88,23 @@ class AttributionFetcher {
#endif
}

// should match OS availability in https://developer.apple.com/documentation/ad_services
@available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *)
var adServicesToken: String? {
#if canImport(AdServices)
do {
return try AAAttribution.attributionToken()
} catch {
let message = Strings.attribution.adservices_token_fetch_failed(error: error)
Logger.appleWarning(message)
return nil
}
#else
Logger.warn(Strings.attribution.adservices_not_supported)
return nil
#endif
}

var isAuthorizedToPostSearchAds: Bool {
// Should match platforms that require permissions detailed in
// https://developer.apple.com/app-store/user-privacy-and-data-use/
Expand Down
31 changes: 24 additions & 7 deletions Sources/Attribution/AttributionNetwork.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,37 +22,43 @@ import Foundation
/**
Apple's search ads
*/
case appleSearchAds
@available(*, deprecated, message: "use adServices")
NachoSoto marked this conversation as resolved.
Show resolved Hide resolved
case appleSearchAds = 0

/**
Adjust https://www.adjust.com/
*/
case adjust
case adjust = 1

/**
AppsFlyer https://www.appsflyer.com/
*/
case appsFlyer
case appsFlyer = 2

/**
Branch https://www.branch.io/
*/
case branch
case branch = 3

/**
Tenjin https://www.tenjin.io/
*/
case tenjin
case tenjin = 4

/**
Facebook https://developers.facebook.com/
*/
case facebook
case facebook = 5

/**
mParticle https://www.mparticle.com/
*/
case mParticle
case mParticle = 6

/**
AdServices token
*/
case adServices = 7

}

Expand All @@ -64,3 +70,14 @@ extension AttributionNetwork: Encodable {
}

}

extension AttributionNetwork {

var isAppleSearchAdds: Bool {
switch self {
case .appleSearchAds: return true
default: return false
}
}

}
91 changes: 70 additions & 21 deletions Sources/Attribution/AttributionPoster.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ class AttributionPoster {
self.subscriberAttributesManager = subscriberAttributesManager
}

// swiftlint:disable:next function_body_length
func post(attributionData data: [String: Any],
fromNetwork network: AttributionNetwork,
networkUserId: String?) {
Expand All @@ -54,19 +53,13 @@ class AttributionPoster {
}

let currentAppUserID = self.currentUserProvider.currentAppUserID
let networkKey = String(network.rawValue)
let latestNetworkIdsAndAdvertisingIdsSentByNetwork =
deviceCache.latestNetworkAndAdvertisingIdsSent(appUserID: currentAppUserID)
let latestSentToNetwork = latestNetworkIdsAndAdvertisingIdsSentByNetwork[networkKey]

let newValueForNetwork = "\(identifierForAdvertisers ?? "(null)")_\(networkUserId ?? "(null)")"
guard latestSentToNetwork != newValueForNetwork else {
Logger.debug(Strings.attribution.skip_same_attributes)
guard let newDictToCache = self.getNewDictToCache(currentAppUserID: currentAppUserID,
idfa: identifierForAdvertisers,
network: network,
networkUserId: networkUserId) else {
return
}

var newDictToCache = latestNetworkIdsAndAdvertisingIdsSentByNetwork
newDictToCache[networkKey] = newValueForNetwork
var newData = data

if let identifierForAdvertisers = identifierForAdvertisers {
Expand All @@ -88,7 +81,7 @@ class AttributionPoster {
}

if !newData.isEmpty {
if network == .appleSearchAds {
if network.isAppleSearchAdds {
postSearchAds(newData: newData,
network: network,
appUserID: currentAppUserID,
Expand All @@ -102,13 +95,13 @@ class AttributionPoster {
}
}

@available(*, deprecated)
func postAppleSearchAdsAttributionIfNeeded() {
guard attributionFetcher.isAuthorizedToPostSearchAds else {
return
}

let latestIdsSent = latestNetworkIdAndAdvertisingIdentifierSent(network: .appleSearchAds)
guard latestIdsSent == nil else {
guard self.latestNetworkIdAndAdvertisingIdentifierSent(network: .appleSearchAds) == nil else {
return
}

Expand All @@ -130,6 +123,23 @@ class AttributionPoster {
}
}

// should match OS availability in https://developer.apple.com/documentation/ad_services
@available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
func postAdServicesTokenIfNeeded() {
let latestTokenSent = latestNetworkIdAndAdvertisingIdentifierSent(network: .adServices)
guard latestTokenSent == nil else {
return
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Since latestTokenSent isn't used in the method we don't need a variable for it (it's kind of confusing because it's always going to be nil. I'd change this to:

Suggested change
let latestTokenSent = latestNetworkIdAndAdvertisingIdentifierSent(network: .adServices)
guard latestTokenSent == nil else {
return
}
guard self.latestNetworkIdAndAdvertisingIdentifierSent(network: .adServices) == nil else {
return
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Bump


guard let attributionToken = attributionFetcher.adServicesToken else {
return
}

self.post(adServicesToken: attributionToken)
}

func postPostponedAttributionDataIfNeeded() {
guard let postponedAttributionData = Self.postponedAttributionData else {
return
Expand All @@ -154,35 +164,74 @@ class AttributionPoster {
postponedAttributionData = postponedData
}

private func post(adServicesToken: String) {
let currentAppUserID = self.currentUserProvider.currentAppUserID

// set the cache in advance to avoid multiple post calls
var newDictToCache = self.deviceCache.latestAdvertisingIdsByNetworkSent(appUserID: currentAppUserID)
newDictToCache[AttributionNetwork.adServices] = adServicesToken
self.deviceCache.set(latestAdvertisingIdsByNetworkSent: newDictToCache, appUserID: currentAppUserID)

backend.post(adServicesToken: adServicesToken, appUserID: currentAppUserID) { error in
guard let error = error else {
Logger.debug(Strings.attribution.adservices_token_post_succeeded)
return
}
Logger.warn(Strings.attribution.adservices_token_post_failed(error: error))

// if there's an error, reset the cache
newDictToCache[AttributionNetwork.adServices] = nil
self.deviceCache.set(latestAdvertisingIdsByNetworkSent: newDictToCache, appUserID: currentAppUserID)
}
}

private func latestNetworkIdAndAdvertisingIdentifierSent(network: AttributionNetwork) -> String? {
let networkID = String(network.rawValue)
let cachedDict = deviceCache.latestNetworkAndAdvertisingIdsSent(
let cachedDict = deviceCache.latestAdvertisingIdsByNetworkSent(
appUserID: self.currentUserProvider.currentAppUserID
)
return cachedDict[networkID]
return cachedDict[network]
}

private func postSearchAds(newData: [String: Any],
network: AttributionNetwork,
appUserID: String,
newDictToCache: [String: String]) {
newDictToCache: [AttributionNetwork: String]) {
backend.post(attributionData: newData, network: network, appUserID: appUserID) { error in
guard error == nil else {
return
}

self.deviceCache.set(latestNetworkAndAdvertisingIdsSent: newDictToCache, appUserID: appUserID)
self.deviceCache.set(latestAdvertisingIdsByNetworkSent: newDictToCache, appUserID: appUserID)
}
}

private func postSubscriberAttributes(newData: [String: Any],
network: AttributionNetwork,
appUserID: String,
newDictToCache: [String: String]) {
newDictToCache: [AttributionNetwork: String]) {
subscriberAttributesManager.setAttributes(fromAttributionData: newData,
network: network,
appUserID: appUserID)
deviceCache.set(latestNetworkAndAdvertisingIdsSent: newDictToCache, appUserID: appUserID)
deviceCache.set(latestAdvertisingIdsByNetworkSent: newDictToCache, appUserID: appUserID)
}

private func getNewDictToCache(currentAppUserID: String,
idfa: String?,
network: AttributionNetwork,
networkUserId: String?) -> [AttributionNetwork: String]? {
let latestAdvertisingIdsByNetworkSent =
deviceCache.latestAdvertisingIdsByNetworkSent(appUserID: currentAppUserID)
let latestSentToNetwork = latestAdvertisingIdsByNetworkSent[network]

let newValueForNetwork = "\(idfa ?? "(null)")_\(networkUserId ?? "(null)")"
guard latestSentToNetwork != newValueForNetwork else {
Logger.debug(Strings.attribution.skip_same_attributes)
return nil
}

var newDictToCache = latestAdvertisingIdsByNetworkSent
newDictToCache[network] = newValueForNetwork
return newDictToCache
}

}
26 changes: 21 additions & 5 deletions Sources/Caching/DeviceCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -274,17 +274,33 @@ class DeviceCache {

// MARK: - attribution

func latestNetworkAndAdvertisingIdsSent(appUserID: String) -> [String: String] {
func latestAdvertisingIdsByNetworkSent(appUserID: String) -> [AttributionNetwork: String] {
return self.userDefaults.read {
let key = CacheKeyBases.attributionDataDefaults + appUserID
let latestNetworkAndAdvertisingIdsSent = $0.object(forKey: key) as? [String: String] ?? [:]
return latestNetworkAndAdvertisingIdsSent
let latestAdvertisingIdsByRawNetworkSent = $0.object(forKey: key) as? [String: String] ?? [:]

let latestSent: [AttributionNetwork: String] =
latestAdvertisingIdsByRawNetworkSent.compactMapKeys { networkKey in
guard let networkRawValue = Int(networkKey),
let attributionNetwork = AttributionNetwork(rawValue: networkRawValue) else {
Logger.error(
Strings.attribution.latest_attribution_sent_user_defaults_invalid(
networkKey: networkKey
)
)
return nil
}
return attributionNetwork
}

return latestSent
}
}

func set(latestNetworkAndAdvertisingIdsSent: [String: String], appUserID: String) {
func set(latestAdvertisingIdsByNetworkSent: [AttributionNetwork: String], appUserID: String) {
self.userDefaults.write {
$0.setValue(latestNetworkAndAdvertisingIdsSent,
let latestAdIdsByRawNetworkStringSent = latestAdvertisingIdsByNetworkSent.mapKeys { String($0.rawValue) }
$0.setValue(latestAdIdsByRawNetworkStringSent,
forKey: CacheKeyBases.attributionDataDefaults + appUserID)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@ Most features require configuring the SDK before using it.
- ``Purchases/finishTransactions``
- ``Purchases/invalidateCustomerInfoCache()``
- ``Purchases/forceUniversalAppStore``
- ``Purchases/automaticAppleSearchAdsAttributionCollection``
- ``Purchases/proxyURL``
- ``Purchases/verboseLogs``
- ``Purchases/verboseLogHandler``
Expand Down
23 changes: 20 additions & 3 deletions Sources/Logging/Strings/AttributionStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ enum AttributionStrings {
case unsynced_attributes(unsyncedAttributes: SubscriberAttribute.Dictionary)
case attribute_set_locally(attribute: String)
case missing_advertiser_identifiers
case unknown_sk2_product_discount_type(rawValue: String)
case adservices_not_supported
case adservices_token_fetch_failed(error: Error)
case adservices_token_post_failed(error: BackendError)
case adservices_token_post_succeeded
case latest_attribution_sent_user_defaults_invalid(networkKey: String)

}

Expand Down Expand Up @@ -115,8 +119,21 @@ extension AttributionStrings: CustomStringConvertible {
case .missing_advertiser_identifiers:
return "Attribution error: identifierForAdvertisers is missing"

case .unknown_sk2_product_discount_type(let rawValue):
Copy link
Contributor

Choose a reason for hiding this comment

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

Good call

return "Failed to create StoreProductDiscount.DiscountType with unknown value: \(rawValue)"
case .adservices_not_supported:
return "Tried to fetch AdServices attribution token on device without " +
"AdServices support."

case .adservices_token_fetch_failed(let error):
return "Fetching AdServices attribution token failed with error: \(error.localizedDescription)"

case .adservices_token_post_failed(let error):
return "Posting AdServices attribution token failed with error: \(error.localizedDescription)"

case .adservices_token_post_succeeded:
return "AdServices attribution token successfully posted"

case .latest_attribution_sent_user_defaults_invalid(let networkKey):
return "Attribution data stored in UserDefaults has invalid format for network key: \(networkKey)"

}
}
Expand Down
5 changes: 5 additions & 0 deletions Sources/Logging/Strings/StoreKitStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ enum StoreKitStrings {

case sk1_no_known_product_type

case unknown_sk2_product_discount_type(rawValue: String)

case sk1_discount_missing_locale

case no_cached_products_starting_store_products_request(identifiers: Set<String>)
Expand Down Expand Up @@ -76,6 +78,9 @@ extension StoreKitStrings: CustomStringConvertible {
return "This StoreProduct represents an SK1 product, the type of product cannot be determined, " +
"the value will be undefined. Use `StoreProduct.productCategory` instead."

case .unknown_sk2_product_discount_type(let rawValue):
return "Failed to create StoreProductDiscount.DiscountType with unknown value: \(rawValue)"

case .sk1_discount_missing_locale:
return "There is an issue with the App Store, this SKProductDiscount is missing a Locale - " +
"The current device Locale will be used instead."
Expand Down
6 changes: 6 additions & 0 deletions Sources/Misc/Deprecations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ public extension Purchases {
)
}

/**
* Enable automatic collection of Apple Search Ads attribution. Defaults to `false`.
*/
@available(*, deprecated, message: "Use Purchases.shared.attribution.enableAdServicesAttributionTokenCollection() instead")
@objc static var automaticAppleSearchAdsAttributionCollection: Bool = false

}

public extension Purchases {
Expand Down
Loading