mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-11 22:55:55 +05:45
* Deserialize audience rules for rules engine evaluation The rules engine takes a predicate as a string, so decoding the audience as an opaque blob would only mean serializing it back before every evaluation. Decode `id` and keep `rules` as the JSON string the engine takes. Keys are sorted on the way out because Swift dictionaries have no order of their own, and an unstable predicate string would be a moving target for anything that caches or compares it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UND779kHH6TRc2662wzaj1 * Evaluate checkpoint audiences Checkpoint rules each target an audience, but resolution took the first served rule regardless. Resolve each rule's audience through the `audiences` topic and evaluate its predicate, so the first rule whose audience the customer matches wins. Predicates resolve one rule at a time, so a rule after the match never pays for a lookup. An audience the SDK couldn't read leaves the outcome unknown, so it reports `configurationUnavailable` rather than `noMatch`, and can't let a lower-priority rule win instead. Also starts honoring `CheckpointParams.customVariables`, which the resolver dropped until now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UND779kHH6TRc2662wzaj1 * Cover a malformed audience falling through to a later rule Ports the one case purchases-android#3971 tests that iOS did not: a predicate the engine can't run is an evaluation failure, not a lookup failure, so a lower-priority rule can still win on its own merits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UND779kHH6TRc2662wzaj1 * Cover served audience payload shapes and the terminal-offering gate Decodes through the real Audience decoder so the predicate under test is what a served payload produces: empty rules match everyone (the engine only dispatches single-key objects, and every object is truthy), the backend's always-false sentinel resolves to a clean non-match, and a predicate on a variable no provider supplies is false rather than an error. Also covers that the audience gate runs before the workflow body picks between presenting and handing back an offering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UND779kHH6TRc2662wzaj1 * Read audiences from the topic item instead of a blob The audiences topic serves each audience inline in its config item, with no blob_ref, so the blob read found nothing and every lookup returned nil. With audience evaluation in place that surfaced as configurationUnavailable for every checkpoint, so nothing presented. Read the item's own content first and keep the blob read as a fallback for any project still served the earlier shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UND779kHH6TRc2662wzaj1 * Pin that a negated unsupplied variable matches everyone An unresolvable path does not fail closed. Under negation it inverts: null == "NL" is false, so ! makes it true and the audience matches every customer, including the ones it was written to exclude. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UND779kHH6TRc2662wzaj1 * Read audiences from item metadata only, matching Android khepri publishes each audience as item metadata with blob_contents=None and clear_blob=True, so the blob path is deliberately dead rather than transitional. Drop the fallback and log the raw metadata before decoding, matching purchases-android#3971. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UND779kHH6TRc2662wzaj1 * Log audience metadata as JSON instead of parsed values The interpolation happened at the call site, so the dictionary was stringified even with debug logging off, and it printed AnyDecodable case names rather than anything a human wants to read. Reuse the JSON already built for decoding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UND779kHH6TRc2662wzaj1 * Check config staleness before resolving the matched rule Audiences are read from their own topic at lookup time, so they are not covered by the rule snapshot's generation. A refresh landing mid-walk meant the match was built from config that no longer existed, and the existing check after resolve() could only discard the result: the workflow fetch, the offerings load and the asset prewarm had already happened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VrDQrA37Fr5XZvpT1KvD8D * Stop the rule walk when the task is cancelled Neither the audience lookup nor RulesEngine.evaluate throws on cancellation, so a cancelled resolution kept decoding audiences and evaluating predicates for every remaining rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VrDQrA37Fr5XZvpT1KvD8D --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
56 lines
1.9 KiB
Swift
56 lines
1.9 KiB
Swift
//
|
|
// AudiencesConfigProvider.swift
|
|
// RevenueCat
|
|
//
|
|
// Created by Cesar de la Vega.
|
|
// Copyright © 2026 RevenueCat, Inc. All rights reserved.
|
|
|
|
import Foundation
|
|
|
|
protocol AudiencesConfigProviderType {
|
|
|
|
func getAudience(_ identifier: String) async -> Audience?
|
|
|
|
}
|
|
|
|
/// The topic-specific front door for audiences, reading through `RemoteConfigManager`'s `audiences` topic.
|
|
final class AudiencesConfigProvider: AudiencesConfigProviderType {
|
|
|
|
private let manager: RemoteConfigManagerType
|
|
|
|
init(manager: RemoteConfigManagerType) {
|
|
self.manager = manager
|
|
}
|
|
|
|
/// Reads the audience from the topic item itself. The backend publishes audiences as item metadata with no
|
|
/// blob at all, so `ConfigItem.content` (every key apart from the reserved `blob_ref` and `prefetch`) is
|
|
/// the whole audience.
|
|
///
|
|
/// An audience the SDK can't decode is dropped on its own, so one bad payload can't take out the others.
|
|
func getAudience(_ identifier: String) async -> Audience? {
|
|
guard let content = await self.manager.topic(.audiences)?[identifier]?.content,
|
|
!content.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
do {
|
|
let data = try JSONSerialization.data(withJSONObject: content.mapValues(\.asAny))
|
|
|
|
// Logged as JSON rather than the parsed values, so what the backend sent is legible when a
|
|
// predicate turns out to behave differently than whoever configured the audience expected.
|
|
Logger.debug(Strings.remoteConfig.audienceMetadataBeforeDecoding(
|
|
identifier: identifier,
|
|
metadata: String(bytes: data, encoding: .utf8) ?? "<non-UTF8>"
|
|
))
|
|
|
|
return try JSONDecoder.default.decode(Audience.self, from: data)
|
|
} catch {
|
|
Logger.error(Strings.codable.decoding_error(error, Audience.self))
|
|
return nil
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
extension AudiencesConfigProvider: @unchecked Sendable {}
|