Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup
### Fixes

- Fixes the SDK getting stuck in test mode when a sheet such as the Customer Center is already open as it finishes loading.
- Stops matching installs for attribution unless the Superwall MMP is turned on for your app.
- Fixes paywalls on first launch missing acquisition attributes in their audience filters: they now wait up to 2 seconds for install attribution to finish.

## 4.17.0

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//

import Foundation
import Combine

/// Thrown by `Network.matchMMPInstall(...)` when the dependencies needed to
/// build the request aren't available yet. Distinct from a transport failure
Expand All @@ -23,15 +24,124 @@ final class MMPAttributionManager {
private unowned let network: Network
private unowned let storage: Storage
private unowned let identityManager: IdentityManager
private unowned let configManager: ConfigManager
private var pendingMatch: AnyCancellable?
private let lock = NSLock()
private var startMatch: (() -> Task<Void, Never>?)?
private var hasStartedMatch = false
/// `true` from when this launch's install match is set up until it finishes
/// or is skipped.
private let isMatchPending = CurrentValueSubject<Bool, Never>(false)

/// How long a paywall waits for an install match that's still running.
static let presentationWaitTimeout: TimeInterval = 2

init(
network: Network,
storage: Storage,
identityManager: IdentityManager
identityManager: IdentityManager,
configManager: ConfigManager
) {
self.network = network
self.storage = storage
self.identityManager = identityManager
self.configManager = configManager
}

/// Calls `startMatch` once config says the MMP is enabled for this app,
/// which may be straight away if config is already loaded. It's off by
/// default, so it never fires if the backend doesn't turn it on. Works the
/// same way `AttributionPoster` waits for Apple Search Ads to be enabled.
///
/// `startMatch` returns the running match, or `nil` if it skipped it
/// because the app has opted out of tracking. A skipped match is tried
/// again when the app opts back in, via `startMatchIfEnabled()`.
func matchInstallOnceEnabled(_ startMatch: @escaping () -> Task<Void, Never>?) {
lock.lock()
self.startMatch = startMatch
lock.unlock()
isMatchPending.send(true)

pendingMatch = configManager.configState
.compactMap { $0.getConfig() }
.map { $0.attribution?.mmp?.enabled == true }
.removeDuplicates()
.filter { $0 }
.sink(
receiveCompletion: { _ in },
receiveValue: { [weak self] _ in
self?.startMatchIfEnabled()
}
)
}

/// Starts this launch's install match if config has the MMP on and it
/// hasn't started yet. Called when config arrives and when the app turns
/// tracking back on.
func startMatchIfEnabled() {
if configManager.config?.attribution?.mmp?.enabled != true {
return
}
lock.lock()
defer { lock.unlock() }
guard
!hasStartedMatch,
let startMatch = startMatch
else {
return
}
guard let match = startMatch() else {
// Skipped while opted out. Don't hold paywalls up in the meantime.
isMatchPending.send(false)
return
}
hasStartedMatch = true
isMatchPending.send(true)
Task { [weak self] in
await match.value
self?.isMatchPending.send(false)
}
}

/// On a first launch, a paywall can be requested while the install match is
/// still running. If the MMP is on and the placement's audiences use
/// `acquisition_*` attributes, this waits for the match, up to `timeout`, so
/// they're there when the audiences are checked. Returns straight away
/// otherwise.
func waitForPendingMatch(
ifUsedBy trigger: Trigger?,
timeout: TimeInterval = presentationWaitTimeout
) async {
guard let trigger = trigger else {
return
}
if !isMatchPending.value {
return
}
if configManager.config?.attribution?.mmp?.enabled != true {
return
}
if !Self.usesAcquisitionAttributes(trigger) {
return
}

let isMatchPending = isMatchPending
await withTaskGroup(of: Void.self) { group in
group.addTask {
_ = try? await isMatchPending.first { !$0 }.throwableAsync()
}
group.addTask {
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
}
await group.next()
group.cancelAll()
}
}

static func usesAcquisitionAttributes(_ trigger: Trigger) -> Bool {
return trigger.audiences.contains { audience in
audience.expression?.contains("acquisition_") == true
}
}

/// Fires the install-attribution match and applies its result.
Expand Down
3 changes: 2 additions & 1 deletion Sources/SuperwallKit/Dependencies/DependencyContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ final class DependencyContainer {
mmpAttributionManager = MMPAttributionManager(
network: network,
storage: storage,
identityManager: identityManager
identityManager: identityManager,
configManager: configManager
)

testModeManager = TestModeManager(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,5 +108,10 @@ extension Superwall {
// Get the identity. This may or may not wait depending on whether the dev
// specifically wants to wait for assignments.
try await dependencyContainer.identityManager.hasIdentity.throwableAsync()

let trigger = request.presentationInfo.placementName.flatMap {
dependencyContainer.configManager.triggersByPlacementName[$0]
}
await dependencyContainer.mmpAttributionManager.waitForPendingMatch(ifUsedBy: trigger)
}
}
65 changes: 40 additions & 25 deletions Sources/SuperwallKit/Superwall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@
set {
options.eventTrackingBehavior = newValue

if newValue != .none {
dependencyContainer.mmpAttributionManager.startMatchIfEnabled()
}

Task {
await dependencyContainer.placementsQueue.setTrackingBehavior(newValue)
}
Expand Down Expand Up @@ -258,7 +262,7 @@

/// Merged statuses that have been stored but not yet emitted, oldest first.
/// Guarded by `subscriptionStatusLock`.
private var pendingSubscriptionStatusEmissions: [SubscriptionStatus] = []

Check warning on line 265 in Sources/SuperwallKit/Superwall.swift

View workflow job for this annotation

GitHub Actions / Package-SwiftLint

Array/Dictionary initializer Violation: Use explicit type annotation when initializing empty arrays and dictionaries (array_constructor)

/// Whether a thread is currently emitting the pending statuses. Guarded by
/// `subscriptionStatusLock`.
Expand Down Expand Up @@ -540,31 +544,42 @@

_ = await configureIdentity

// Skip install-attribution matching entirely when the developer has
// opted out of all event collection. The `/api/match` call and the
// `acquisition_*` attribute writes happen outside the event queue, so
// queue-level suppression wouldn't catch them.
if dependencyContainer.configManager.options.eventTrackingBehavior != .none,
dependencyContainer.storage.shouldAttemptInitialMMPInstallAttributionMatch(
hadTrackedAppInstallBeforeConfigure: hadTrackedAppInstallBeforeConfigure,
appInstalledAtString: dependencyContainer.deviceHelper.appInstalledAtString
) {
let advertiserTrackingEnabled =
dependencyContainer.permissionHandler.checkTrackingPermission() == .granted

// We deliberately fire the match once and don't retry after ATT is
// granted: the backend matches on IP + device fingerprint + time decay,
// not IDFA, so a post-consent re-match wouldn't change the result. And
// because matches are time-decayed and reads are latest-wins, a later
// retry could only tie or worsen the earlier, better-timed match.
// (`idfa`/`advertiserTrackingEnabled` are sent for downstream use, not
// matching.)
dependencyContainer.storage.recordMMPInstallAttributionMatch {
await dependencyContainer.mmpAttributionManager.matchInstall(
idfa: dependencyContainer.attributionFetcher.identifierForAdvertisers,
advertiserTrackingEnabled: advertiserTrackingEnabled,
applicationTrackingEnabled: true
)
// The eligibility check has to run at launch whatever the config or
// tracking setting says: it records that this install may be matched,
// which a later launch relies on if this one ends before the match
// completes. Only the request itself waits for config to enable the MMP.
if dependencyContainer.storage.shouldAttemptInitialMMPInstallAttributionMatch(
hadTrackedAppInstallBeforeConfigure: hadTrackedAppInstallBeforeConfigure,
appInstalledAtString: dependencyContainer.deviceHelper.appInstalledAtString
) {
dependencyContainer.mmpAttributionManager.matchInstallOnceEnabled { [weak dependencyContainer] in
guard let dependencyContainer = dependencyContainer else {
return nil
}
// Skip matching when the app has opted out of all event collection.
// The `/api/match` call and the `acquisition_*` attribute writes
// happen outside the event queue, so queue-level suppression
// wouldn't catch them. It's tried again if the app opts back in.
if dependencyContainer.configManager.options.eventTrackingBehavior == .none {
return nil
}
Comment thread
yusuftor marked this conversation as resolved.
let advertiserTrackingEnabled =
dependencyContainer.permissionHandler.checkTrackingPermission() == .granted

// We deliberately fire the match once and don't retry after ATT is
// granted: the backend matches on IP + device fingerprint + time decay,
// not IDFA, so a post-consent re-match wouldn't change the result. And
// because matches are time-decayed and reads are latest-wins, a later
// retry could only tie or worsen the earlier, better-timed match.
// (`idfa`/`advertiserTrackingEnabled` are sent for downstream use, not
// matching.)
return dependencyContainer.storage.recordMMPInstallAttributionMatch {
await dependencyContainer.mmpAttributionManager.matchInstall(
idfa: dependencyContainer.attributionFetcher.identifierForAdvertisers,
advertiserTrackingEnabled: advertiserTrackingEnabled,
applicationTrackingEnabled: true
)
}
}
}

Expand Down
4 changes: 4 additions & 0 deletions SuperwallKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@
D25B3A24CEE42FC90BFA31D2 /* SuperwallEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75E4096EBF0B8C9693322CD1 /* SuperwallEvent.swift */; };
D2E381B26362F434760F9AC0 /* GameControllerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 577A16EE2161E2CDEDFA48C0 /* GameControllerManager.swift */; };
D330BD5F814AC52E6D04C7E7 /* InternalGetPaywall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C9F31221C5E26E20514E0C1 /* InternalGetPaywall.swift */; };
D40450C91B0EA225DC361C93 /* MMPInstallMatchConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C95686C305E939873FFFEC2 /* MMPInstallMatchConfigTests.swift */; };
D443CC56731E9A20E4F43F4C /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20365697A9C396E8EC746B77 /* LoadingViewController.swift */; };
D461F38D019122194A9CB38C /* PaywallRequestBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F71D7A7DC8FFB72CA13296 /* PaywallRequestBody.swift */; };
D4B5A9708204AB6D58904733 /* GetPaywallVcOperatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943CA7F8AE219A07C1245232 /* GetPaywallVcOperatorTests.swift */; };
Expand Down Expand Up @@ -945,6 +946,7 @@
5C2E30544869C5469AA31832 /* FactoryProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactoryProtocols.swift; sourceTree = "<group>"; };
5C3EFD2725CAE7F5046D386F /* AppStoreVersionLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreVersionLookup.swift; sourceTree = "<group>"; };
5C57C1CCAF97244AE0DC953F /* PaywallManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallManagerMock.swift; sourceTree = "<group>"; };
5C95686C305E939873FFFEC2 /* MMPInstallMatchConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPInstallMatchConfigTests.swift; sourceTree = "<group>"; };
5CD130C74880AD07DCD2A7AA /* RedemptionResultObjc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedemptionResultObjc.swift; sourceTree = "<group>"; };
5D44CEC91693B4B900472C1C /* Survey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Survey.swift; sourceTree = "<group>"; };
5D8D539E4636D23E549B4520 /* TestModePurchaseDrawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModePurchaseDrawer.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3373,6 +3375,7 @@
A82783401B92298C47BF14F7 /* AdServicesAttributionTests.swift */,
43C6C8966D32B889BEA06F70 /* AttributionDeviceIdentifiersTests.swift */,
6B7CFAF4B3E32AE628A249C8 /* AttributionTests.swift */,
5C95686C305E939873FFFEC2 /* MMPInstallMatchConfigTests.swift */,
);
path = Attribution;
sourceTree = "<group>";
Expand Down Expand Up @@ -3837,6 +3840,7 @@
40314E44991DCB66B4572C25 /* LocationPermissionDelegateTests.swift in Sources */,
4DE01655FC4CC148DD3D161C /* LoggerMock.swift in Sources */,
556DDBA011967A3F2411AAE7 /* MMPInstallAttributionTests.swift in Sources */,
D40450C91B0EA225DC361C93 /* MMPInstallMatchConfigTests.swift in Sources */,
6838BDF35DFEB69351777883 /* MMPMatchResponseTests.swift in Sources */,
A9B924A1211117378743A534 /* MicrophonePermissionTests.swift in Sources */,
B294572426111EC04F225289 /* MockExternalPurchaseControllerFactory.swift in Sources */,
Expand Down
Loading
Loading