Skip to content

feat(ads): add rewarded-ad reward event types and AdTracker methods#6843

Merged
polmiro merged 46 commits into
mainfrom
pol/ads-reward-event-types
Jun 1, 2026
Merged

feat(ads): add rewarded-ad reward event types and AdTracker methods#6843
polmiro merged 46 commits into
mainfrom
pol/ads-reward-event-types

Conversation

@polmiro

@polmiro polmiro commented May 26, 2026

Copy link
Copy Markdown
Member

Motivation

The ad-events pipeline (loaded / displayed / opened / revenue / failed-to-load) had no
way to represent a user earning a rewarded-ad reward or a reward being verified.
This blocks reward-funnel metrics: how many rewarded ads produce a reward, server-side
verification success rate, and time-to-verification.

What this PR does

  • Adds three @_spi(Internal) reward event types — AdRewardEarnedUnverified,
    AdRewardVerified, AdRewardFailedToVerify — plus an AdRewardFailureReason enum,
    all conforming to AdImpressionEventData.
  • Adds matching AdTracker.trackAdReward* methods, the internal AdEvent cases, and the
    wire mapping.
  • Promotes the @_spi(Experimental) AdReward / VirtualCurrencyReward payload types
    from the AdMob adapter into the core SDK, removing the adapter's duplicate
    VerifiedReward. The AdMob adapter, sample app, and docs are updated to consume
    AdReward.
Wire event Swift type AdTracker method
rc_ads_ad_reward_sdk_unverified AdRewardEarnedUnverified trackAdRewardEarnedUnverified(_:)
rc_ads_ad_reward_sdk_verified AdRewardVerified trackAdRewardVerified(_:)
rc_ads_ad_reward_sdk_failed_to_verify AdRewardFailedToVerify trackAdRewardFailedToVerify(_:)

Wire event names keep the _sdk_ infix to preserve the backend ingestion contract that
distinguishes SDK-observed events from backend SSV-webhook events.

Testing

  • Unit tests: equality + Codable round-trip for the reward types; decoder fallback to
    .unsupportedReward (with warning log) on unknown kind / malformed virtual-currency
    payload; wire-JSON snapshots for all reward events; Purchases.shared.adTracker
    integration coverage.

Note

Medium Risk
Touches reward verification public/experimental types and ad event ingestion; wire schema is extended but backward-compatible, while adapter apps must follow the AdReward shape instead of the removed VerifiedReward type.

Overview
Introduces reward lifecycle analytics in the core SDK: three @_spi(Internal) event payloads (AdRewardEarnedUnverified, AdRewardVerified, AdRewardFailedToVerify) plus matching AdTracker.trackAdReward* methods and wire types (rc_ads_ad_reward_sdk_*) with new optional fields on AdEventsRequest.

Consolidates verified-reward modeling by adding @_spi(Experimental) AdReward in the core SDK (replacing the internal VerifiedReward enum and the adapter’s duplicate VerifiedReward struct). RewardVerificationResult.verifiedReward now exposes AdReward?; the adapter passes rewards through without mapVerifiedReward. VirtualCurrencyReward uses a failable initializer (empty code / non-positive amount → invalid); invalid payloads decode to .unsupportedReward with warnings instead of adapter-side assertions.

The AdMob adapter, sample app, and docs are updated to use reward.virtualCurrency on AdReward. RewardVerificationResult.isFailed is removed in favor of equality with .failed.

Reviewed by Cursor Bugbot for commit 5ec035c. Bugbot is set up for automated code reviews on this repo. Configure here.

@emerge-tools

emerge-tools Bot commented May 26, 2026

Copy link
Copy Markdown

4 builds increased size

Name Version Download Change Install Change Approval
RevenueCat
com.revenuecat.PaywallsTester
1.0 (1) 18.2 MB ⬆️ 59.5 kB (0.33%) 65.5 MB ⬆️ 242.0 kB (0.37%) N/A
BinarySizeTest
com.revenuecat.binary-size-test.local-source
1.0 (1) 4.2 MB ⬆️ 22.7 kB (0.55%) 12.5 MB ⬆️ 67.9 kB (0.55%) ⏳ Needs approval
BinarySizeTest
com.revenuecat.binary-size-test.cocoapods
1.0 (1) 6.3 MB ⬆️ 32.7 kB (0.52%) 27.6 MB ⬆️ 146.6 kB (0.54%) ⏳ Needs approval
BinarySizeTest
com.revenuecat.binary-size-test.spm
1.0 (1) 4.3 MB ⬆️ 22.4 kB (0.53%) 10.9 MB ⬆️ 58.8 kB (0.54%) ⏳ Needs approval

RevenueCat 1.0 (1)
com.revenuecat.PaywallsTester

⚖️ Compare build
⏱️ Analyze build performance

Total install size change: ⬆️ 242.0 kB (0.37%)
Total download size change: ⬆️ 59.5 kB (0.33%)

Largest size changes

Item Install Size Change
DYLD.String Table ⬆️ 72.4 kB
RevenueCat.InternalAPI.InternalAPI ⬆️ 11.7 kB
Code Signature ⬆️ 6.0 kB
DYLD.Exports ⬆️ 5.1 kB
Strings.Unmapped ⬆️ 728 B
View Treemap

Image of diff

BinarySizeTest 1.0 (1)
com.revenuecat.binary-size-test.local-source

⚖️ Compare build
📦 Install build
⏱️ Analyze build performance

Total install size change: ⬆️ 67.9 kB (0.55%)
Total download size change: ⬆️ 22.7 kB (0.55%)

Largest size changes

Item Install Size Change
DYLD.String Table ⬆️ 5.7 kB
RevenueCat.AdEvent.value witness ⬆️ 3.5 kB
DYLD.Exports ⬆️ 2.9 kB
📝 RevenueCat.AdRewardVerified.init(from) ⬆️ 2.5 kB
📝 RevenueCat.AdRewardEarnedUnverified.init(from) ⬆️ 2.2 kB
View Treemap

Image of diff

BinarySizeTest 1.0 (1)
com.revenuecat.binary-size-test.cocoapods

⚖️ Compare build
📦 Install build
⏱️ Analyze build performance

Total install size change: ⬆️ 146.6 kB (0.54%)
Total download size change: ⬆️ 32.7 kB (0.52%)

Largest size changes

Item Install Size Change
DYLD.String Table ⬆️ 58.0 kB
Code Signature ⬆️ 4.1 kB
RevenueCat.AdEvent.value witness ⬆️ 3.5 kB
DYLD.Exports ⬆️ 3.0 kB
📝 RevenueCat.AdRewardVerified.init(from) ⬆️ 2.5 kB
View Treemap

Image of diff

BinarySizeTest 1.0 (1)
com.revenuecat.binary-size-test.spm

⚖️ Compare build
📦 Install build
⏱️ Analyze build performance

Total install size change: ⬆️ 58.8 kB (0.54%)
Total download size change: ⬆️ 22.4 kB (0.53%)

Largest size changes

Item Install Size Change
RevenueCat.AdEvent.value witness ⬆️ 3.5 kB
DYLD.Exports ⬆️ 2.9 kB
📝 RevenueCat.AdRewardVerified.init(from) ⬆️ 2.5 kB
📝 RevenueCat.AdRewardEarnedUnverified.init(from) ⬆️ 2.2 kB
📝 RevenueCat.AdRewardFailedToVerify.init(from) ⬆️ 1.8 kB
View Treemap

Image of diff


🛸 Powered by Emerge Tools

Comment trigger: Size diff threshold of 100.00kB exceeded

@polmiro polmiro requested a review from ajpallares May 27, 2026 09:10
@polmiro polmiro marked this pull request as ready for review May 27, 2026 09:10
@polmiro polmiro requested a review from a team as a code owner May 27, 2026 09:10
polmiro added 7 commits May 27, 2026 11:45
Adds two rawValue-based public classes that will be used as discriminators
and failure-reason values on the upcoming reward-SDK event types.

Both follow the existing MediatorName/AdFormat/Precision pattern: @objc
NSObject subclass with rawValue String, public static constants, Codable,
equality/hash overrides, @_spi(Experimental).

Using rawValue classes (not Swift enums) keeps the public API extensible
without source-breaking exhaustive-switch consumers, per CLAUDE.md.

No existing code references these types yet; they will be wired up in
the following commits.
Adds three public event-data classes for the rewarded-ad reward lifecycle:

- AdRewardEarnedUnverified: fires when the ad SDK reports a user-earned
  reward before server-side verification completes. Carries
  rewardVerificationEnabled, rewardItem (AdMob reward.type), and
  rewardAmount.
- AdRewardVerified: fires when server-side verification confirms the
  reward. Carries rewardType (discriminator) plus optional virtual-currency
  code/amount fields populated only for .virtualCurrency.
- AdRewardFailedToVerify: fires on terminal verification failure. Carries
  a structured failureReason.

All three conform to AdImpressionEventData (mediatorName/adFormat/placement/
adUnitId/impressionId + optional networkName) and follow the existing
AdLoaded/AdRevenue shape: NSObject subclass, @_spi(Experimental),
@objc(RC...) name, Codable, @unchecked Sendable, private(set) properties,
NSObject equality/hash overrides. Optional NSNumber fields use the existing
private rawValue Int? + computed NSNumber? pattern from AdFailedToLoad.

The internal AdEvent enum gains three matching cases and the
creationData/eventData/impressionIdentifier/networkName/mediatorErrorCode
helpers are extended exhaustively. Three new accessors are added for
reward-specific data (rewardEarnedUnverifiedData, rewardVerifiedData,
rewardFailedToVerifyData) for use by the upcoming wire-schema mapping.

EventType cases are added to AdEventsRequest to keep the eventType switch
exhaustive; wire raw values keep the rc_ads_ad_reward_sdk_* prefix to
preserve the cross-platform backend ingestion contract. The full wire-field
mapping lands in a follow-up commit.
Adds three @_spi(Experimental) @objc methods on AdTracker mirroring the
existing trackAdRevenue pattern:

- trackAdRewardEarnedUnverified(_:) — fires when the ad SDK reports a
  user-earned reward before verification.
- trackAdRewardVerified(_:) — fires when verification confirms the reward.
- trackAdRewardFailedToVerify(_:) — fires when verification terminally
  fails.

Each constructs the matching AdEvent case and dispatches to the events
manager via a fire-and-forget Task, exactly like the other track* methods.
This exposes reward-event tracking to apps that don't use the AdMob
adapter; the adapter will route through these in a follow-up commit.
Adds the reward-event wire fields to AdEventsRequest.AdEventRequest:

- rewardVerificationEnabled, rewardItem, rewardAmount (earned/unverified)
- rewardType, rewardCurrencyCode, rewardCurrencyAmount (verified)
- failureReason (failed-to-verify)

The init?(storedEvent:) mapping reads the three new accessors on AdEvent
(rewardEarnedUnverifiedData, rewardVerifiedData, rewardFailedToVerifyData)
introduced in the prior commit, so the fields are populated only for the
matching event type and null for all others. CodingKeys gains entries for
each new field; snake_case conversion turns them into reward_verification_
enabled / reward_item / reward_amount / reward_type / reward_currency_code
/ reward_currency_amount / failure_reason on the wire.

EventType cases for the three reward events were added in the prior commit
to keep that commit's switch exhaustive; this commit completes the wire
schema by adding the corresponding field mapping.
Extends the existing AdEventTests, AdEventsRequestTests, and
PurchasesAdEventsTests files (matching the codebase convention of
one file per category covering all event variants):

- AdEventTests: equality assertions for each new reward type (same /
  different properties), nil-field acceptance for optional reward fields,
  rawValue stability for AdRewardType / AdRewardFailureReason static
  constants, and Codable round-trips for all three reward data classes.
- AdEventsRequestTests: snapshot tests covering the wire JSON payloads
  for the three new reward event types, with the corresponding committed
  snapshot fixtures.
- PurchasesAdEventsTests: integration tests verifying that calling
  Purchases.shared.adTracker.trackAdReward* writes the expected AdEvent
  case into mockEventsManager.trackedAdEvents with all fields preserved.

Snapshot fixtures verify the snake_case wire format produced by the
JSONEncoder.KeyEncodingStrategy.convertToSnakeCase strategy applied
to AdEventsRequest.AdEventRequest. The wire event names retain the
_sdk_ infix (rc_ads_ad_reward_sdk_unverified, etc.) per the
cross-platform backend ingestion contract.
Extends RCAdTrackerAPI to exercise the new reward public surface so
ObjC compatibility breakage is caught by the API tester target in CI.
Covers:

- RCAdRewardType (static constants, raw-value initializer, rawValue property).
- RCAdRewardFailureReason (static constants, raw-value initializer,
  rawValue property).
- RCAdRewardEarnedUnverified (full initializer with NSNumber rewardAmount,
  every public property accessor).
- RCAdRewardVerified (full initializer with NSNumber rewardCurrencyAmount,
  every public property accessor).
- RCAdRewardFailedToVerify (full initializer, every public property accessor).
- AdTracker.trackAdRewardEarnedUnverified / trackAdRewardVerified /
  trackAdRewardFailedToVerify.

Note: the api/*.swiftinterface baselines do not include @_spi(Experimental)
declarations (verified by the existing AdLoaded / AdRevenue / MediatorName
also being absent from the baselines), so the new reward types do not
appear in those files and regeneration produces no diff. The ObjC API
tester is the active compatibility check for this surface.
Fixes CI build failures: pass `nil as Int?` for amount parameters to
pick the Int? overload over NSNumber?, and qualify `AdRewardType.noReward`
to disambiguate from `VerifiedReward.noReward`.
Comment thread Sources/Ads/Events/AdEvent.swift Outdated
Comment thread Sources/Ads/Events/AdEvent.swift Outdated
Comment thread Sources/Ads/Events/AdEvent.swift Outdated
polmiro and others added 4 commits May 27, 2026 12:27
Co-authored-by: Antonio Pallares <ajpallares@users.noreply.github.com>
Co-authored-by: Antonio Pallares <ajpallares@users.noreply.github.com>
Replaces the AdRewardType discriminator + flat rewardCurrencyCode/Amount
fields on AdRewardVerified with a single reward: AdReward property that
bundles kind and payload together. AdReward also subsumes the adapter's
VerifiedReward and the internal RevenueCat.VerifiedReward enum, so all
three modules share one type.

- New AdReward struct at @_spi(Experimental) in the main SDK with
  virtualCurrency / noReward / unsupportedReward factories and a
  kindRawValue accessor for wire encoding and ObjC interop.
- Promotes VirtualCurrencyReward from @_spi(Internal) to @_spi(Experimental).
- AdRewardVerified gains custom Codable that preserves the existing
  rewardType / rewardCurrencyCode / rewardCurrencyAmount wire keys, plus
  @objc derived accessors (rewardKindRawValue, virtualCurrencyCode,
  virtualCurrencyAmount) and an ObjC init taking the flat fields.
- Adapter's VerifiedReward struct removed; RewardVerificationResult now
  carries AdReward and exposes it via a renamed `reward` property.
@polmiro polmiro requested a review from ajpallares May 27, 2026 14:38
Comment thread Sources/Ads/RewardVerification/AdReward.swift Outdated
Comment thread Sources/Ads/RewardVerification/RewardVerificationPollStatus.swift
Comment thread Sources/Ads/Events/AdEvent.swift Outdated
Comment thread Sources/Ads/RewardVerification/AdReward.swift Outdated
Re-aligns these types with the rest of the ads + reward verification
surface, which is uniformly @_spi(Experimental). Keeps them out of
RevenueCat's stable public namespace until the feature stabilizes.
Comment thread Sources/Ads/Events/AdEvent.swift Outdated
polmiro added 2 commits May 27, 2026 18:38
The ObjC convenience init shared its reward-construction helper with the
Codable decoder, so invalid developer inputs (kindRawValue
"virtual_currency" with nil code or amount) silently produced an
unsupportedReward with no diagnostic. Split the helpers: a developer-input
path that logs and asserts via AdsStrings.missing_virtual_currency_reward_fields,
and a decoder path that stays intentionally silent (malformed wire data
isn't a programming bug).
polmiro added 10 commits May 28, 2026 14:42
…FailedToVerify to struct

AdRewardFailureReason becomes a Swift enum with String raw values
(timeout / network_error / backend_error / unknown). The enum forces
exhaustive switches at compile time when new failure reasons are
added — a stronger guarantee than the previous class-with-rawValue
pattern.

AdRewardFailedToVerify becomes a plain struct (parallel to the
AdRewardEarnedUnverified and AdRewardVerified conversions): drops
@objc/NSObject/equality overrides, since the enum-typed
`failureReason` can no longer be represented in Objective-C anyway.
…to public

Consistency with AdReward's .noReward / .unsupportedReward static lets:
external devs now write `if result == .failed` rather than `if result.isFailed`,
using the same equality idiom they already use for AdReward cases.

`.failed` is a parameter-less singleton with no provenance to forge, so
promoting it to `public` introduces no construction-safety concern.
… reward

The previous example called AdReward.virtualCurrency(code:amount:), which is
internal — SPI consumers cannot construct it. Show the realistic flow where
the AdReward comes from the reward-verification pipeline.
AdRewardEarnedUnverified, AdRewardVerified, and AdRewardFailedToVerify are
value types whose stored properties are all Sendable lets. Plain Sendable
turns on the compiler check we were opting out of for no reason — the
@unchecked was a copy-paste from the @objc NSObject-backed event classes.
Both are final NSObject subclasses with only let properties, so they're
trivially Sendable-safe. Declaring @unchecked Sendable on them lets the
reward event structs that hold them use checked Sendable instead of
@unchecked, closing the diagnostic warning surfaced by the previous commit.
When AdRewardVerified's decoder hits an unknown reward kind or a malformed
virtual_currency payload, it correctly falls back to .unsupportedReward
without crashing — but until now it did so silently, hiding backend/SDK
schema drift. Add two AdsStrings warnings and emit them on the fallback
paths, plus three tests covering unknown kind, missing amount, and
non-positive amount.
…a protocol

Both accessors previously enumerated every case; all non-failedToLoad data
types already conform to AdImpressionEventData, so a single cast on the
existing eventData reads both fields. Future ad-event cases that conform
to AdImpressionEventData get covered automatically.
The previous one-liners ('Data for ad reward earned (unverified) events.')
repeated the type name. Replace with descriptions that explain what moment
the event represents, mirroring the case-level docs on AdEvent.
Swiftlint flags the previous AdEventTests body as over the type_body_length
limit even before this PR. Move the three new decoder-fallback tests into
an extension on AdEventTests, and consolidate the JSON construction into a
shared helper so each test reads as a one-line behavioural assertion.
Adds parameterized `encode<K:CodingKey>(into:typeKey:codeKey:amountKey:)`
and `decode<K:CodingKey>(from:typeKey:codeKey:amountKey:)` helpers on
AdReward, and has AdRewardVerified delegate to them. The wire layout
(rewardType / rewardCurrencyCode / rewardCurrencyAmount) is unchanged.

Adding a new AdReward.Kind now only requires editing AdReward (factory,
Kind raw value, decode branch) — AdRewardVerified stays untouched unless
the new kind introduces new wire fields.
@polmiro polmiro requested a review from ajpallares May 29, 2026 07:49
Comment thread Sources/Ads/AdTracker.swift
Comment thread Tests/UnitTests/Ads/Events/AdEventsRequestTests.swift
Comment thread Sources/Ads/RewardVerification/VirtualCurrencyReward.swift
Comment thread Tests/UnitTests/Ads/Events/AdEventTests.swift Outdated
Comment thread Tests/UnitTests/Ads/Events/AdEventTests.swift Outdated
Comment thread Sources/Ads/Events/AdEvent.swift
Comment thread Sources/Logging/Strings/AdsStrings.swift Outdated
polmiro added 6 commits May 29, 2026 15:47
…gle validation point

Routes AdReward.virtualCurrency(code:amount:), AdReward.decode, and
RewardVerificationStatusResponse through VirtualCurrencyReward.init?(code:amount:),
which rejects empty code or non-positive amount. Drops the now-redundant
AdsStrings.invalid_virtual_currency_amount.
Moves AdRewardFailureReason and the three AdReward* event structs out of
AdEvent.swift into a new file, and registers it in the Xcode project. The
file_length disable on AdEvent.swift stays since the remainder is still
above the 400-line limit.
@polmiro polmiro requested a review from ajpallares May 29, 2026 14:51

@ajpallares ajpallares left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looking good! Just a coupe of suggestions

  1. PR description. Could you update it (e.g. still references outdated RCAdTrackerAPI.m file) and please 🙏 make it smaller so that it's easier to read?
  2. Small code change below

Comment thread Sources/Ads/RewardVerification/AdReward.swift Outdated
@polmiro

polmiro commented Jun 1, 2026

Copy link
Copy Markdown
Member Author

Updated the PR description: trimmed it down, removed the stale ObjC RCAdTrackerAPI.m references (the reward types are @_spi(Internal), not @objc, so they aren't exercised in the API testers), and corrected the framing since the AdReward consolidation lives in this PR rather than a follow-up. 🙏

@ajpallares ajpallares left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for iterating on this!

I would only change the PR description to make it completely up-to-date and to make it easier (and quicker) to read, only keeping the essential. But that's not a blocker for merging as it can be updated after the fact

@polmiro polmiro requested a review from ajpallares June 1, 2026 09:53
@polmiro polmiro merged commit 362a0c7 into main Jun 1, 2026
41 of 42 checks passed
@polmiro polmiro deleted the pol/ads-reward-event-types branch June 1, 2026 10:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants