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

[CF-106] Fetch AdServices Token #1519

Merged
merged 45 commits into from
May 2, 2022
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
f3131f9
Add new configuration option automaticAdServicesAttributionTokenColle…
Apr 20, 2022
d322c7c
Add postAdServicesTokenIfNeeded, call in same places as old iAD calls
Apr 20, 2022
dbcdc4c
Fetch and post ad services token
Apr 20, 2022
c557ba5
Fix incorrect available check
Apr 20, 2022
882a936
Pull discount string from attribution strings to StoreKitStrings
Apr 20, 2022
21aa633
Merge branch 'main' into ad-services
Apr 20, 2022
cc93cdb
fetching unit tests
Apr 20, 2022
9706097
formatting/renaming
Apr 20, 2022
c98031a
Remove code for fetching
Apr 20, 2022
03357e7
Use canImport
Apr 20, 2022
bcd58a2
Update availability checks, move to Deprecations.swift
Apr 21, 2022
5c957b7
Merge branch 'ad-services' of github.com:RevenueCat/purchases-ios int…
Apr 21, 2022
4c6332c
Remove some todos, fix formatting
Apr 21, 2022
4a952c4
fix lint
Apr 22, 2022
3811408
fix mockattributionfetcher
Apr 25, 2022
e02c0d2
API testers
Apr 25, 2022
81f2628
Merge branch 'ad-services-sdk' into ad-services
Apr 25, 2022
47ecfff
move rest of deprecations to deprecations.swift
Apr 25, 2022
d51c37f
Add comment about OS availability
Apr 25, 2022
f2014f8
Revert "move rest of deprecations to deprecations.swift"
Apr 25, 2022
8923389
Add explicit integer values for attribution networks
Apr 25, 2022
e5dea3a
Fix deprecation warning ...
Apr 25, 2022
10a073b
update fastlane
Apr 25, 2022
352de9e
Revert "Fix deprecation warning ..."
Apr 26, 2022
bae8e3c
skipping setting properties on de-init
Apr 26, 2022
d4a552a
Update docc and AttributionNetwork api tester
Apr 26, 2022
c181dc7
Remove iad code
Apr 26, 2022
f29aedb
deprecate attributionnetwork enum value
Apr 26, 2022
d6d3dc7
remove superfluous lint disable
Apr 27, 2022
67b3e7a
remove iad-associated unit tests
Apr 27, 2022
4d800ac
Merge branch 'ad-services-sdk' into ad-services
Apr 27, 2022
2b21373
fix more unit tests
Apr 27, 2022
61c28a3
Fix bad merges
Apr 27, 2022
26b02f7
remove PostAttributionDataOperation and AfficheClientProxy
Apr 27, 2022
a11742a
Make adServicesToken a var
Apr 28, 2022
6a7697b
use do catch, more details in error log
Apr 28, 2022
60db73b
formatting
Apr 28, 2022
1df28a0
Update Sources/Purchasing/Purchases.swift
Apr 28, 2022
3e3d383
linking optional
Apr 28, 2022
cc98cbe
directly return
Apr 28, 2022
839bc8d
`CustomerInfo`: moved deprecated property to `Deprecations` (#1549)
NachoSoto Apr 28, 2022
2c16386
make error not optional
Apr 28, 2022
42bbabc
Merge branch 'main' into ad-services
Apr 28, 2022
3de0061
Add AdServices.framework to APITesters
joshdholtz Apr 29, 2022
d9f757b
Don't link `AdServices.framework` on `watchOS` & `tvOS` (#1554)
NachoSoto Apr 29, 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
8 changes: 6 additions & 2 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -274,11 +274,11 @@
57DE806D28074976008D6C6F /* Storefront.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57DE806C28074976008D6C6F /* Storefront.swift */; };
57DE807128074C23008D6C6F /* SK1Storefront.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57DE807028074C23008D6C6F /* SK1Storefront.swift */; };
57DE807328074C76008D6C6F /* SK2Storefront.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57DE807228074C76008D6C6F /* SK2Storefront.swift */; };
57DE80BE28077010008D6C6F /* XCTestCase+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D22BF6626F3CBFB001AE2F9 /* XCTestCase+Extensions.swift */; };
57DE80BF2807705F008D6C6F /* XCTestCase+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D22BF6626F3CBFB001AE2F9 /* XCTestCase+Extensions.swift */; };
57DE80892807540D008D6C6F /* StorefrontTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57DE80882807540D008D6C6F /* StorefrontTests.swift */; };
57DE80AE28075D77008D6C6F /* OSVersionEquivalent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57DE80AD28075D77008D6C6F /* OSVersionEquivalent.swift */; };
57DE80AF28075D77008D6C6F /* OSVersionEquivalent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57DE80AD28075D77008D6C6F /* OSVersionEquivalent.swift */; };
57DE80BE28077010008D6C6F /* XCTestCase+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D22BF6626F3CBFB001AE2F9 /* XCTestCase+Extensions.swift */; };
57DE80BF2807705F008D6C6F /* XCTestCase+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D22BF6626F3CBFB001AE2F9 /* XCTestCase+Extensions.swift */; };
57E0473B277260DE0082FE91 /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 57E0473A277260DE0082FE91 /* SnapshotTesting */; };
57E2230727500BB1002DB06E /* AtomicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57E2230627500BB1002DB06E /* AtomicTests.swift */; };
57EAE527274324C60060EB74 /* Lock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57EAE526274324C60060EB74 /* Lock.swift */; };
Expand All @@ -304,6 +304,7 @@
A563F586271E072B00246E0C /* MockBeginRefundRequestHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A563F585271E072B00246E0C /* MockBeginRefundRequestHelper.swift */; };
A563F589271E1DAD00246E0C /* MockBeginRefundRequestHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A563F585271E072B00246E0C /* MockBeginRefundRequestHelper.swift */; };
A56F9AB126990E9200AFC48F /* CustomerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56F9AB026990E9200AFC48F /* CustomerInfo.swift */; };
A5B6CDD8280F3843007629D5 /* AdServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5B6CDD5280F3843007629D5 /* AdServices.framework */; };
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be weakly linked?

Copy link
Contributor

Choose a reason for hiding this comment

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

In case I don't get to review this again, just wanted to point out that this is probably my most critical comment. I believe this has to be weakly linked so we don't make all users of the SDK implicitly link AdServices.

Copy link
Member

Choose a reason for hiding this comment

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

+1 on this, this part is a very delicate change that we should research and test out thoroughly.
It might even be a red flag for privacy-minded folks who're looking into using our SDK. Perhaps we can do the extra work of calling this through the same "reflection" we did for iAd?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

are you both okay with me doing the linking research as the follow-up ticket i created? would definitely be before merging into main. this just feels like enough work to warrant a separate PR

Copy link
Member

Choose a reason for hiding this comment

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

absolutely! It's only important to get the linking right before merging to main, but it can totally be done as a separate thing

Copy link
Contributor Author

Choose a reason for hiding this comment

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

okay then that's the plan!

A5F0104E2717B3150090732D /* BeginRefundRequestHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F0104D2717B3150090732D /* BeginRefundRequestHelper.swift */; };
B300E4BF26D436F900B22262 /* LogIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A65DFDD258AD60A00DE00B0 /* LogIntent.swift */; };
B300E4C026D4371200B22262 /* SKPaymentTransactionExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F591492526B994B400D32E58 /* SKPaymentTransactionExtensionsTests.swift */; };
Expand Down Expand Up @@ -734,6 +735,7 @@
A563F585271E072B00246E0C /* MockBeginRefundRequestHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBeginRefundRequestHelper.swift; sourceTree = "<group>"; };
A563F587271E076800246E0C /* BeginRefundRequestHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeginRefundRequestHelperTests.swift; sourceTree = "<group>"; };
A56F9AB026990E9200AFC48F /* CustomerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerInfo.swift; sourceTree = "<group>"; };
A5B6CDD5280F3843007629D5 /* AdServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AdServices.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX12.3.sdk/System/Library/Frameworks/AdServices.framework; sourceTree = DEVELOPER_DIR; };
A5F0104D2717B3150090732D /* BeginRefundRequestHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeginRefundRequestHelper.swift; sourceTree = "<group>"; };
B302206927271BCB008F1A0D /* Decoder+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decoder+Extensions.swift"; sourceTree = "<group>"; };
B302206D2728B798008F1A0D /* BackendErrorStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackendErrorStrings.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -842,6 +844,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A5B6CDD8280F3843007629D5 /* AdServices.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -1326,6 +1329,7 @@
3530C18722653E8F00D6DF52 /* Frameworks */ = {
isa = PBXGroup;
children = (
A5B6CDD5280F3843007629D5 /* AdServices.framework */,
B36824BD268FBC5B00957E4C /* XCTest.framework */,
2DE20B9126409ECF004C597D /* StoreKit.framework */,
2DE20B7526408806004C597D /* StoreKitTest.framework */,
Expand Down
19 changes: 19 additions & 0 deletions Sources/Attribution/AttributionFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import UIKit
import WatchKit
#endif

#if os(iOS)
beylmk marked this conversation as resolved.
Show resolved Hide resolved
import AdServices
#endif

enum AttributionFetcherError: Error {

case identifierForAdvertiserUnavailableForPlatform
Expand Down Expand Up @@ -82,6 +86,21 @@ class AttributionFetcher {
#endif
}

@available(iOS 14.3, *)
func adServicesToken(completion: @escaping (String?, Error?) -> Void) {
// TODO check for library?
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 canImport would help here too :)

#if os(iOS)
do {
let attributionToken = try AAAttribution.attributionToken()
completion(attributionToken, nil)
} catch let attributionTokenError {
completion(nil, attributionTokenError)
}
#endif
// todo make error
completion(nil, nil)
}

var isAuthorizedToPostSearchAds: Bool {
// Should match platforms that require permissions detailed in
// https://developer.apple.com/app-store/user-privacy-and-data-use/
Expand Down
5 changes: 5 additions & 0 deletions Sources/Attribution/AttributionNetwork.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import Foundation
*/
@objc(RCAttributionNetwork) public enum AttributionNetwork: Int {

/**
AdServices token
*/
case adServices

/**
Apple's search ads
*/
Expand Down
20 changes: 19 additions & 1 deletion Sources/Attribution/AttributionPoster.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class AttributionPoster {

var newDictToCache = latestNetworkIdsAndAdvertisingIdsSentByNetwork
newDictToCache[networkKey] = newValueForNetwork

var newData = data

if let identifierForAdvertisers = identifierForAdvertisers {
Expand Down Expand Up @@ -130,6 +131,23 @@ class AttributionPoster {
}
}

@available(iOS 14.3, *)
func postAdServicesTokenIfNeeded() {
let latestTokenSent = latestNetworkIdAndAdvertisingIdentifierSent(network: .adServices)
guard latestTokenSent == nil else {
return
}

attributionFetcher.adServicesToken { token, error in
guard let attributionToken = token,
error == nil else {
return
}

// TODO post
}
}

func postPostponedAttributionDataIfNeeded() {
guard let postponedAttributionData = Self.postponedAttributionData else {
return
Expand Down Expand Up @@ -160,7 +178,7 @@ class AttributionPoster {
appUserID: self.currentUserProvider.currentAppUserID
)
return cachedDict[networkID]
}
}

private func postSearchAds(newData: [String: Any],
network: AttributionNetwork,
Expand Down
5 changes: 0 additions & 5 deletions Sources/Logging/Strings/AttributionStrings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ enum AttributionStrings {
case unsynced_attributes(unsyncedAttributes: SubscriberAttributeDict)
case attribute_set_locally(attribute: String)
case missing_advertiser_identifiers
case unknown_sk2_product_discount_type(rawValue: String)

}

Expand Down Expand Up @@ -114,10 +113,6 @@ extension AttributionStrings: CustomStringConvertible {

case .missing_advertiser_identifiers:
return "Attribution error: identifierForAdvertisers is missing"

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

}
}

Expand Down
6 changes: 6 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)

}

extension StoreKitStrings: CustomStringConvertible {
Expand Down Expand Up @@ -71,6 +73,10 @@ extension StoreKitStrings: CustomStringConvertible {
case .sk1_no_known_product_type:
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):
Copy link
Contributor

Choose a reason for hiding this comment

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

👍🏻

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

}
}

Expand Down
31 changes: 29 additions & 2 deletions Sources/Purchasing/Purchases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,10 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
private let operationDispatcher: OperationDispatcher

/**
* Enable automatic collection of Apple Search Ads attribution. Defaults to `false`.
* Enable automatic collection of AdServices attribution token. Defaults to `false`.
*/
@objc public static var automaticAppleSearchAdsAttributionCollection: Bool = false
@available(iOS 14.3, *)
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 this will need the tvOS/macCatalyst (macOS too?) equivalents too

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yep good call. xcode never suggests the full checks for us, do they...

Copy link
Contributor

Choose a reason for hiding this comment

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

Nope, only for the currently selected target unfortunately.

@objc public static var automaticAdServicesAttributionTokenCollection: Bool = false

/**
* Used to set the log level. Useful for debugging issues with the lovely team @RevenueCat.
Expand Down Expand Up @@ -417,6 +418,10 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
attributionPoster.postPostponedAttributionDataIfNeeded()
postAppleSearchAddsAttributionCollectionIfNeeded()

if #available(iOS 14.3, *) {
postAdServicesTokenIfNeeded()
}

self.customerInfoObservationDisposable = customerInfoManager.monitorChanges { [weak self] customerInfo in
guard let self = self else { return }
self.delegate?.purchases?(self, receivedUpdated: customerInfo)
Expand All @@ -439,6 +444,10 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
customerInfoObservationDisposable?()
privateDelegate = nil
Self.automaticAppleSearchAdsAttributionCollection = false

if #available(iOS 14.3, *) {
Self.automaticAdServicesAttributionTokenCollection = false
}
Self.proxyURL = nil
}

Expand Down Expand Up @@ -721,6 +730,14 @@ extension Purchases {
attributionPoster.postAppleSearchAdsAttributionIfNeeded()
}

@available(iOS 14.3, *)
private func postAdServicesTokenIfNeeded() {
guard Self.automaticAdServicesAttributionTokenCollection else {
return
}
attributionPoster.postAdServicesTokenIfNeeded()
}

}

// MARK: Identity
Expand Down Expand Up @@ -1822,6 +1839,12 @@ extension Purchases: PurchasesOrchestratorDelegate {

public extension Purchases {

/**
* Enable automatic collection of Apple Search Ads attribution. Defaults to `false`.
*/
@available(*, deprecated, message: "use Purchases.automaticAdServicesAttributionTokenCollection for iOS 14.3+ instead")
@objc static var automaticAppleSearchAdsAttributionCollection: Bool = false
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe move this to Deprecations.swift?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

is there a difference between methods in Deprecations.swift and those in the // MARK: Deprecated section of Purchases.swift?

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh I didn't realize we still had some there. The idea was to make the main file (which is already huge) smaller by only having the current API.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

makes sense to me! i can move the rest while i'm at it...


/**
* Enable debug logging. Useful for debugging issues with the lovely team @RevenueCat.
*/
Expand Down Expand Up @@ -1908,6 +1931,10 @@ private extension Purchases {
updateAllCachesIfNeeded()
dispatchSyncSubscriberAttributesIfNeeded()
postAppleSearchAddsAttributionCollectionIfNeeded()

if #available(iOS 14.3, *) {
postAdServicesTokenIfNeeded()
}
}

@objc func applicationWillResignActive(notification: Notification) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ extension StoreProductDiscount.DiscountType {
case SK2ProductDiscount.OfferType.promotional:
return .promotional
default:
Logger.warn(Strings.attribution.unknown_sk2_product_discount_type(rawValue: sk2Discount.type.rawValue))
Logger.warn(Strings.storeKit.unknown_sk2_product_discount_type(rawValue: sk2Discount.type.rawValue))
Copy link
Contributor

Choose a reason for hiding this comment

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

:+1

return nil
}
}
Expand Down
3 changes: 2 additions & 1 deletion Sources/SubscriberAttributes/AttributionDataMigrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ private extension AttributionDataMigrator {
networkSpecificSubscriberAttributes = [:]
case .mParticle:
networkSpecificSubscriberAttributes = convertMParticleAttribution(attributionData)
case .none, .appleSearchAds:
// TODO maddie confirm this is correct
case .none, .appleSearchAds, .adServices:
// Apple Search Ads uses standard attribution system
networkSpecificSubscriberAttributes = [:]
}
Expand Down
7 changes: 7 additions & 0 deletions Tests/UnitTests/Mocks/MockAttributionFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

class MockAttributionFetcher: AttributionFetcher {

var adServicesTokenCollected = false

override var identifierForAdvertisers: String? {
return "rc_idfa"
}
Expand All @@ -20,4 +22,9 @@ class MockAttributionFetcher: AttributionFetcher {
) {
completionHandler(["Version3.1": ["iad-campaign-id": 15292426, "iad-attribution": true] as NSObject], nil)
}

override func adServicesToken(completion: @escaping (String?, Error?) -> Void) {
adServicesTokenCollected = true
completion("test", nil)
}
}
23 changes: 23 additions & 0 deletions Tests/UnitTests/Purchasing/PurchasesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@ class PurchasesTests: XCTestCase {

func setupPurchases(automaticCollection: Bool = false) {
Purchases.automaticAppleSearchAdsAttributionCollection = automaticCollection
if #available(iOS 14.3, *) {
Purchases.automaticAdServicesAttributionTokenCollection = automaticCollection
}
self.identityManager.mockIsAnonymous = false

initializePurchasesInstance(appUserId: identityManager.currentAppUserID)
Expand Down Expand Up @@ -2231,6 +2234,26 @@ class PurchasesTests: XCTestCase {
expect(self.backend.invokedPostAttributionDataParameters).to(beNil())
}

@available(iOS 14.3, *)
func testAdServicesAttributionTokenIsAutomaticallyCollected() throws {
guard #available(iOS 14.3, *) else {
throw XCTSkip("Required API is not available for this test.")
}

setupPurchases(automaticCollection: true)
expect(self.attributionFetcher.adServicesTokenCollected) == true
}

@available(iOS 14.3, *)
func testAdServicesAttributionTokenIsNotAutomaticallyCollectedIfDisabled() throws {
guard #available(iOS 14.3, *) else {
throw XCTSkip("Required API is not available for this test.")
}

setupPurchases(automaticCollection: false)
expect(self.attributionFetcher.adServicesTokenCollected) == false
}

func testAttributionDataPostponesMultiple() {
let data = ["yo": "dog", "what": 45, "is": ["up"]] as [String: Any]

Expand Down