Skip to content

Commit 16f1e01

Browse files
polmiroJayShortway
andauthored
Add iOS ad tracking API support (#648)
* Implement iOS ad tracking bridge in KMP SDK Implements the iOS side of ad tracking by bridging to PurchasesHybridCommon: - Replaces error() placeholders with actual implementations - Calls RCCommonFunctionality bridge functions for all 5 ad tracking methods - Converts KMP data models to dictionary format for Objective-C bridge - Includes iOS 15.0+ availability checks with graceful fallback - Logs warnings on unsupported OS versions and errors on failures This completes the iOS implementation for KMP ad tracking APIs. The implementation will work once PurchasesHybridCommon is updated to include the bridge functions. * Adds missing imports to AdTracker.ios.kt. * Fix error message method calls in AdTracker.ios.kt Changed it.message to it.message() for proper method invocation on error objects in all ad tracking completion handlers. * Fix NSNumber constructor usage in AdTracker.ios.kt Use named parameters for NSNumber constructors: - NSNumber(long = data.revenueMicros) for Long values - NSNumber(int = it) for Int values This resolves overload resolution ambiguity errors. * Add ad tracking testing screen to composeApp sample Adds a comprehensive testing screen for the ad tracking functionality with buttons to test all 5 ad tracking methods: - trackAdDisplayed() - Tests ad impression tracking - trackAdOpened() - Tests ad click tracking - trackAdRevenue() - Tests revenue tracking with $5.00 test value - trackAdLoaded() - Tests successful ad load tracking - trackAdFailedToLoad() - Tests failed ad load tracking with error code Each test uses realistic sample data with different ad networks (AdMob, AppLovin), placements, and provides visual feedback on success/failure. * Fix multiplatform compatibility in AdTrackingTestingScreen Replace System.currentTimeMillis() with Clock.System.now().toEpochMilliseconds() to ensure compatibility across all Kotlin Multiplatform targets (iOS, Android, etc). System.currentTimeMillis() is JVM-specific and not available in common code. Using kotlinx.datetime.Clock provides the same functionality in a multiplatform way. * Add support for ad_format * Convert AdFormat from value class to regular class Avoid Java name-mangling caused by Kotlin value classes in the public API. Value classes cause method names like `getAdFormat-AVhMwwk()` instead of the expected `getAdFormat()`. * Convert AdMediatorName from value class to regular class Avoid Java name-mangling caused by Kotlin value classes in the public API. Value classes cause method names like `getMediatorName-WzqQsPc()` instead of the expected `getMediatorName()`. * Convert AdRevenuePrecision from value class to regular class Avoid Java name-mangling caused by Kotlin value classes in the public API. Value classes cause method names like `getPrecision-C-W9r9E()` instead of the expected `getPrecision()`. * Remove internal implementation details from AdTracker.ios.kt docs This is public documentation, so it shouldn't mention implementation details. * Remove try-catch from AdTrackingTestingScreen The tracking functions are fire-and-forget and don't throw exceptions. Keep the sample minimal. * Update purchases-hybrid-common to 17.31.0 * Replace objcnames imports with PurchasesHybridCommonUI imports Updates PaywallOptionsKtx.kt and UIKitPaywall.kt to use explicit PurchasesHybridCommonUI imports instead of objcnames for RCCustomerInfo, RCPackage, RCStoreTransaction, and RCOffering. * Extract ad tracking dictionary keys to constants Addresses PR review feedback to reduce string duplication. --------- Co-authored-by: JayShortway <29483617+JayShortway@users.noreply.github.com>
1 parent 5d2d6fa commit 16f1e01

File tree

26 files changed

+624
-132
lines changed

26 files changed

+624
-132
lines changed
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
package com.revenuecat.purchases.kmp.sample
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.Column
6+
import androidx.compose.foundation.layout.Spacer
7+
import androidx.compose.foundation.layout.fillMaxSize
8+
import androidx.compose.foundation.layout.fillMaxWidth
9+
import androidx.compose.foundation.layout.height
10+
import androidx.compose.foundation.layout.padding
11+
import androidx.compose.foundation.rememberScrollState
12+
import androidx.compose.foundation.shape.RoundedCornerShape
13+
import androidx.compose.foundation.verticalScroll
14+
import androidx.compose.material.Button
15+
import androidx.compose.material.MaterialTheme
16+
import androidx.compose.material.Text
17+
import androidx.compose.material.TextButton
18+
import androidx.compose.runtime.Composable
19+
import androidx.compose.runtime.getValue
20+
import androidx.compose.runtime.mutableStateOf
21+
import androidx.compose.runtime.remember
22+
import androidx.compose.runtime.setValue
23+
import androidx.compose.ui.Modifier
24+
import androidx.compose.ui.graphics.Color
25+
import androidx.compose.ui.text.font.FontFamily
26+
import androidx.compose.ui.text.style.TextAlign
27+
import androidx.compose.ui.unit.dp
28+
import com.revenuecat.purchases.kmp.ExperimentalRevenueCatApi
29+
import com.revenuecat.purchases.kmp.Purchases
30+
import com.revenuecat.purchases.kmp.models.AdDisplayedData
31+
import com.revenuecat.purchases.kmp.models.AdFailedToLoadData
32+
import com.revenuecat.purchases.kmp.models.AdFormat
33+
import com.revenuecat.purchases.kmp.models.AdLoadedData
34+
import com.revenuecat.purchases.kmp.models.AdMediatorName
35+
import com.revenuecat.purchases.kmp.models.AdOpenedData
36+
import com.revenuecat.purchases.kmp.models.AdRevenueData
37+
import com.revenuecat.purchases.kmp.models.AdRevenuePrecision
38+
import kotlinx.datetime.Clock
39+
40+
enum class AdTrackingFunction(val displayName: String) {
41+
TRACK_AD_DISPLAYED("trackAdDisplayed()"),
42+
TRACK_AD_OPENED("trackAdOpened()"),
43+
TRACK_AD_REVENUE("trackAdRevenue()"),
44+
TRACK_AD_LOADED("trackAdLoaded()"),
45+
TRACK_AD_FAILED_TO_LOAD("trackAdFailedToLoad()")
46+
}
47+
48+
@OptIn(ExperimentalRevenueCatApi::class)
49+
@Composable
50+
fun AdTrackingTestingScreen(
51+
navigateTo: (Screen) -> Unit
52+
) {
53+
var statusMessage by remember { mutableStateOf<String?>(null) }
54+
var lastTrackedFunction by remember { mutableStateOf<AdTrackingFunction?>(null) }
55+
var messageColor by remember { mutableStateOf(Color.Gray) }
56+
57+
fun clearStatus() {
58+
statusMessage = null
59+
lastTrackedFunction = null
60+
}
61+
62+
fun trackAdDisplayed() {
63+
val data = AdDisplayedData(
64+
networkName = "TestNetwork",
65+
mediatorName = AdMediatorName.AD_MOB,
66+
adFormat = AdFormat.BANNER,
67+
placement = "home_banner",
68+
adUnitId = "ca-app-pub-1234567890",
69+
impressionId = "test-impression-${Clock.System.now().toEpochMilliseconds()}"
70+
)
71+
Purchases.sharedInstance.adTracker.trackAdDisplayed(data)
72+
statusMessage = "Ad displayed event tracked successfully!"
73+
lastTrackedFunction = AdTrackingFunction.TRACK_AD_DISPLAYED
74+
messageColor = Color.Green
75+
}
76+
77+
fun trackAdOpened() {
78+
val data = AdOpenedData(
79+
networkName = "TestNetwork",
80+
mediatorName = AdMediatorName.AD_MOB,
81+
adFormat = AdFormat.BANNER,
82+
placement = "home_banner",
83+
adUnitId = "ca-app-pub-1234567890",
84+
impressionId = "test-impression-${Clock.System.now().toEpochMilliseconds()}"
85+
)
86+
Purchases.sharedInstance.adTracker.trackAdOpened(data)
87+
statusMessage = "Ad opened event tracked successfully!"
88+
lastTrackedFunction = AdTrackingFunction.TRACK_AD_OPENED
89+
messageColor = Color.Green
90+
}
91+
92+
fun trackAdRevenue() {
93+
val data = AdRevenueData(
94+
networkName = "TestNetwork",
95+
mediatorName = AdMediatorName.APP_LOVIN,
96+
adFormat = AdFormat.REWARDED,
97+
placement = "rewarded_video",
98+
adUnitId = "ca-app-pub-1234567890",
99+
impressionId = "test-impression-${Clock.System.now().toEpochMilliseconds()}",
100+
revenueMicros = 5000000L, // $5.00 in micros
101+
currency = "USD",
102+
precision = AdRevenuePrecision.EXACT
103+
)
104+
Purchases.sharedInstance.adTracker.trackAdRevenue(data)
105+
statusMessage = "Ad revenue event tracked successfully!\nRevenue: $5.00 USD (5,000,000 micros)\nPrecision: EXACT"
106+
lastTrackedFunction = AdTrackingFunction.TRACK_AD_REVENUE
107+
messageColor = Color.Green
108+
}
109+
110+
fun trackAdLoaded() {
111+
val data = AdLoadedData(
112+
networkName = "TestNetwork",
113+
mediatorName = AdMediatorName.AD_MOB,
114+
adFormat = AdFormat.INTERSTITIAL,
115+
placement = "interstitial",
116+
adUnitId = "ca-app-pub-1234567890",
117+
impressionId = "test-impression-${Clock.System.now().toEpochMilliseconds()}"
118+
)
119+
Purchases.sharedInstance.adTracker.trackAdLoaded(data)
120+
statusMessage = "Ad loaded event tracked successfully!"
121+
lastTrackedFunction = AdTrackingFunction.TRACK_AD_LOADED
122+
messageColor = Color.Green
123+
}
124+
125+
fun trackAdFailedToLoad() {
126+
val data = AdFailedToLoadData(
127+
networkName = "TestNetwork",
128+
mediatorName = AdMediatorName.APP_LOVIN,
129+
adFormat = AdFormat.BANNER,
130+
placement = "banner",
131+
adUnitId = "ca-app-pub-1234567890",
132+
mediatorErrorCode = 404
133+
)
134+
Purchases.sharedInstance.adTracker.trackAdFailedToLoad(data)
135+
statusMessage = "Ad failed to load event tracked successfully!\nError code: 404"
136+
lastTrackedFunction = AdTrackingFunction.TRACK_AD_FAILED_TO_LOAD
137+
messageColor = Color.Green
138+
}
139+
140+
Column(
141+
modifier = Modifier
142+
.fillMaxSize()
143+
.verticalScroll(rememberScrollState())
144+
.padding(16.dp)
145+
) {
146+
Text(
147+
text = "Ad Tracking Testing",
148+
modifier = Modifier.fillMaxWidth(),
149+
textAlign = TextAlign.Center,
150+
style = MaterialTheme.typography.h4
151+
)
152+
153+
Spacer(modifier = Modifier.height(16.dp))
154+
155+
Text(
156+
text = "Test all ad tracking events with sample data",
157+
modifier = Modifier.fillMaxWidth(),
158+
textAlign = TextAlign.Center,
159+
color = Color.Gray,
160+
style = MaterialTheme.typography.body1
161+
)
162+
163+
Spacer(modifier = Modifier.height(24.dp))
164+
165+
// Ad Displayed
166+
Button(
167+
onClick = { trackAdDisplayed() },
168+
modifier = Modifier.fillMaxWidth()
169+
) {
170+
Text("Track Ad Displayed")
171+
}
172+
173+
Spacer(modifier = Modifier.height(12.dp))
174+
175+
// Ad Opened
176+
Button(
177+
onClick = { trackAdOpened() },
178+
modifier = Modifier.fillMaxWidth()
179+
) {
180+
Text("Track Ad Opened")
181+
}
182+
183+
Spacer(modifier = Modifier.height(12.dp))
184+
185+
// Ad Revenue
186+
Button(
187+
onClick = { trackAdRevenue() },
188+
modifier = Modifier.fillMaxWidth()
189+
) {
190+
Text("Track Ad Revenue ($5.00)")
191+
}
192+
193+
Spacer(modifier = Modifier.height(12.dp))
194+
195+
// Ad Loaded
196+
Button(
197+
onClick = { trackAdLoaded() },
198+
modifier = Modifier.fillMaxWidth()
199+
) {
200+
Text("Track Ad Loaded")
201+
}
202+
203+
Spacer(modifier = Modifier.height(12.dp))
204+
205+
// Ad Failed to Load
206+
Button(
207+
onClick = { trackAdFailedToLoad() },
208+
modifier = Modifier.fillMaxWidth()
209+
) {
210+
Text("Track Ad Failed to Load")
211+
}
212+
213+
Spacer(modifier = Modifier.height(24.dp))
214+
215+
// Status message display
216+
statusMessage?.let { message ->
217+
Box(
218+
modifier = Modifier
219+
.fillMaxWidth()
220+
.background(
221+
color = messageColor.copy(alpha = 0.1f),
222+
shape = RoundedCornerShape(8.dp)
223+
)
224+
.padding(16.dp)
225+
) {
226+
Column {
227+
Text(
228+
text = message,
229+
color = messageColor.copy(alpha = 0.8f),
230+
style = MaterialTheme.typography.body2
231+
)
232+
lastTrackedFunction?.let { function ->
233+
Spacer(modifier = Modifier.height(8.dp))
234+
Text(
235+
text = "Function: ${function.displayName}",
236+
fontFamily = FontFamily.Monospace,
237+
color = Color.Gray,
238+
style = MaterialTheme.typography.caption
239+
)
240+
}
241+
}
242+
}
243+
Spacer(modifier = Modifier.height(16.dp))
244+
245+
Button(
246+
onClick = { clearStatus() },
247+
modifier = Modifier.fillMaxWidth()
248+
) {
249+
Text("Clear Status")
250+
}
251+
}
252+
253+
Spacer(modifier = Modifier.height(32.dp))
254+
255+
// Info section
256+
Box(
257+
modifier = Modifier
258+
.fillMaxWidth()
259+
.background(
260+
color = Color.Blue.copy(alpha = 0.07f),
261+
shape = RoundedCornerShape(8.dp)
262+
)
263+
.padding(16.dp)
264+
) {
265+
Column {
266+
Text(
267+
text = "Testing Tips:",
268+
style = MaterialTheme.typography.h6
269+
)
270+
Spacer(modifier = Modifier.height(8.dp))
271+
Text(
272+
text = "• Each button tracks a different ad event type\n" +
273+
"• Events use sample data with test values\n" +
274+
"• Check the RevenueCat dashboard to verify events\n" +
275+
"• Ad tracking is an experimental API",
276+
style = MaterialTheme.typography.body2,
277+
color = Color.Black.copy(alpha = 0.7f)
278+
)
279+
}
280+
}
281+
282+
Spacer(modifier = Modifier.height(32.dp))
283+
284+
TextButton(
285+
onClick = { navigateTo(Screen.Main) },
286+
modifier = Modifier.fillMaxWidth()
287+
) {
288+
Text("Go back")
289+
}
290+
}
291+
}

composeApp/src/commonMain/kotlin/com/revenuecat/purchases/kmp/sample/App.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ fun App() {
103103
is Screen.VirtualCurrencyTesting -> VirtualCurrencyTestingScreen(
104104
navigateTo = navigateTo
105105
)
106+
107+
is Screen.AdTracking -> AdTrackingTestingScreen(
108+
navigateTo = navigateTo
109+
)
106110
}
107111
}
108112
}

composeApp/src/commonMain/kotlin/com/revenuecat/purchases/kmp/sample/MainScreen.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,14 @@ fun MainScreen(
182182
) {
183183
Text("Virtual Currency Testing")
184184
}
185+
186+
Spacer(modifier = Modifier.size(16.dp))
187+
TextButton(
188+
onClick = { navigateTo(Screen.AdTracking) },
189+
modifier = Modifier.fillMaxWidth()
190+
) {
191+
Text("Ad Tracking Testing")
192+
}
185193
}
186194
}
187195
}

composeApp/src/commonMain/kotlin/com/revenuecat/purchases/kmp/sample/Screen.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ sealed interface Screen {
1010
data object WinBackTesting : Screen
1111
data object CustomerCenter : Screen
1212
data object VirtualCurrencyTesting : Screen
13+
data object AdTracking : Screen
1314

1415
}

core/core.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Pod::Spec.new do |spec|
99
spec.vendored_frameworks = 'build/cocoapods/framework/Purchases.framework'
1010
spec.libraries = 'c++'
1111
spec.ios.deployment_target = '13.0'
12-
spec.dependency 'PurchasesHybridCommon', '17.30.0'
12+
spec.dependency 'PurchasesHybridCommon', '17.31.0'
1313

1414
if !Dir.exist?('build/cocoapods/framework/Purchases.framework') || Dir.empty?('build/cocoapods/framework/Purchases.framework')
1515
raise "

0 commit comments

Comments
 (0)