Skip to content

Apple Ads Attribution (without IDFA) #5610

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

Merged
merged 1 commit into from
Jun 5, 2025
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- changed: Remove boot logo animation on Android.
- added: Additional user information fields for Kado OTC orders
- added: Apple AdServices integration and reporting
- added: KeyboardAccessoryView-based `KavButton`
- changed: `FiatPluginEnterAmountScene` next button to use `KavButton`
- changed: `SendScene2` row/card grouping updated
Expand Down
6 changes: 6 additions & 0 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,8 @@ PODS:
- React-jsinspector (0.71.15)
- React-logger (0.71.15):
- glog
- react-native-adservices (0.1.3):
- React-Core
- react-native-camera (1.13.1):
- React
- react-native-camera/RCT (= 1.13.1)
Expand Down Expand Up @@ -788,6 +790,7 @@ DEPENDENCIES:
- React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
- React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector`)
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
- "react-native-adservices (from `../node_modules/@brigad/react-native-adservices`)"
- react-native-camera (from `../node_modules/react-native-camera`)
- "react-native-compat (from `../node_modules/@walletconnect/react-native-compat`)"
- react-native-contacts (from `../node_modules/react-native-contacts`)
Expand Down Expand Up @@ -967,6 +970,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/jsinspector"
React-logger:
:path: "../node_modules/react-native/ReactCommon/logger"
react-native-adservices:
:path: "../node_modules/@brigad/react-native-adservices"
react-native-camera:
:path: "../node_modules/react-native-camera"
react-native-compat:
Expand Down Expand Up @@ -1144,6 +1149,7 @@ SPEC CHECKSUMS:
React-jsiexecutor: 5cc0fdcf933aedb0ea9146172a9668ff38e5cac9
React-jsinspector: 4ade58a6a355d97a53f847543b14f4cb5033cb70
React-logger: fc6e4fa928e8773ebfb7f1d287fb6ae60715545a
react-native-adservices: 18087a4a5106c5133b0cde73701bf875e3163cfc
react-native-camera: 806a323ba17579a335ee43a70622f6b8aae98de8
react-native-compat: c5c101132cacb720def1c72b40cdf0398e34d31e
react-native-contacts: f4efe82376487f1b87411e22b867e85cc4fb1d3c
Expand Down
4 changes: 4 additions & 0 deletions ios/edge.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
812B284944A10A722B22763D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08ADAD14914ABC9FD7D54456 /* ExpoModulesProvider.swift */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
BF115CD26A29F1C032E30289 /* Pods_edge.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E261C56FB78E202F218C2DCA /* Pods_edge.framework */; };
E469AC702DC43791006A2530 /* AdServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E469AC6F2DC43791006A2530 /* AdServices.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand Down Expand Up @@ -85,6 +86,7 @@
5B7EB9410499542E8C5724F5 /* Pods-edge-edgeTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-edge-edgeTests.debug.xcconfig"; path = "Target Support Files/Pods-edge-edgeTests/Pods-edge-edgeTests.debug.xcconfig"; sourceTree = "<group>"; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = edge/LaunchScreen.storyboard; sourceTree = "<group>"; };
E261C56FB78E202F218C2DCA /* Pods_edge.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_edge.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E469AC6F2DC43791006A2530 /* AdServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AdServices.framework; path = System/Library/Frameworks/AdServices.framework; sourceTree = SDKROOT; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */

Expand All @@ -94,6 +96,7 @@
buildActionMask = 2147483647;
files = (
BF115CD26A29F1C032E30289 /* Pods_edge.framework in Frameworks */,
E469AC702DC43791006A2530 /* AdServices.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand All @@ -119,6 +122,7 @@
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
isa = PBXGroup;
children = (
E469AC6F2DC43791006A2530 /* AdServices.framework */,
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
E261C56FB78E202F218C2DCA /* Pods_edge.framework */,
);
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"bip39": "3.0.4"
},
"dependencies": {
"@brigad/react-native-adservices": "^0.1.3",
"@connectedcars/react-native-slide-charts": "^1.0.5",
"@ethersproject/shims": "^5.6.0",
"@react-native-async-storage/async-storage": "^1.19.4",
Expand Down
104 changes: 98 additions & 6 deletions src/actions/FirstOpenActions.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,36 @@
import { getAttributionToken } from '@brigad/react-native-adservices'
import { asNumber, asObject, asOptional, asString, asValue } from 'cleaners'
import { makeReactNativeDisklet } from 'disklet'
import { Platform } from 'react-native'

import { FIRST_OPEN } from '../constants/constantSettings'
import { makeUuid } from '../util/rnUtils'
import { snooze } from '../util/utils'
import { getCountryCodeByIp } from './AccountReferralActions'

export const firstOpenDisklet = makeReactNativeDisklet()

const asFirstOpenInfo = asObject({
const asAppleAdsAttribution = asObject({
campaignId: asOptional(asNumber),
keywordId: asOptional(asNumber)
})
type AppleAdsAttribution = ReturnType<typeof asAppleAdsAttribution>

interface FirstOpenInfo {
isFirstOpen: 'true' | 'false'
deviceId: string
firstOpenEpoch: number
countryCode?: string
appleAdsAttribution?: AppleAdsAttribution
}
type FirstOpenInfoFile = Omit<FirstOpenInfo, 'appleAdsAttribution'>

const asFirstOpenInfoFile = asObject<FirstOpenInfoFile>({
isFirstOpen: asValue('true', 'false'),
deviceId: asString,
firstOpenEpoch: asNumber,
countryCode: asOptional(asString)
})
type FirstOpenInfo = ReturnType<typeof asFirstOpenInfo>

let firstOpenInfo: FirstOpenInfo
let firstLoadPromise: Promise<FirstOpenInfo> | undefined
Expand All @@ -40,11 +57,19 @@ const readFirstOpenInfoFromDisk = async (): Promise<FirstOpenInfo> => {
let firstOpenText
try {
firstOpenText = await firstOpenDisklet.getText(FIRST_OPEN)
firstOpenInfo = asFirstOpenInfo(JSON.parse(firstOpenText))
firstOpenInfo.isFirstOpen = 'false'
// Parse the file data using the file-specific cleaner
const fileData = asFirstOpenInfoFile(JSON.parse(firstOpenText))
// Create the full in-memory object with attribution data
firstOpenInfo = {
...fileData,
isFirstOpen: 'false',
appleAdsAttribution: await getAppleAdsAttribution()
}
} catch (error: unknown) {
// Generate new values.
firstOpenInfo = {

// Create file data object (without attribution)
const fileData: FirstOpenInfoFile = {
deviceId: await makeUuid(),
firstOpenEpoch: Date.now(),
countryCode: await getCountryCodeByIp(),
Expand All @@ -53,11 +78,78 @@ const readFirstOpenInfoFromDisk = async (): Promise<FirstOpenInfo> => {
// date, just created an empty file.
// Note that 'firstOpenEpoch' won't be accurate in this case, but at
// least make a starting point.

isFirstOpen: firstOpenText != null ? 'false' : 'true'
}
await firstOpenDisklet.setText(FIRST_OPEN, JSON.stringify(firstOpenInfo))

// Create the full in-memory object
firstOpenInfo = {
...fileData,
appleAdsAttribution: await getAppleAdsAttribution()
}

// Only save the file-specific data to disk
await firstOpenDisklet.setText(FIRST_OPEN, JSON.stringify(fileData))
}
}

return firstOpenInfo
}

/**
* Get Apple Search Ads attribution data using the AdServices framework
* and make an API call to get the actual keywordId.
*/
export async function getAppleAdsAttribution(): Promise<AppleAdsAttribution> {
if (Platform.OS !== 'ios') {
return { campaignId: undefined, keywordId: undefined }
}

// Get the attribution token from the device. This package also handles
// checking for the required iOS version.
const attributionToken = await getAttributionToken().catch(error => {
console.log('Apple Ads attribution token unavailable:', error)
return undefined
})

// Send the token to Apple's API to retrieve the campaign and keyword IDs.
if (attributionToken != null) {
// Retry logic as recommended by Apple:
// "A 404 response can occur if you make an API call too quickly after
// receiving a valid token. A best practice is to initiate retries at
// intervals of 5 seconds, with a maximum of three attempts."
// https://developer.apple.com/documentation/adservices/aaattribution/attributiontoken()#Attribution-payload
const maxRetries = 3
const retryDelay = 5000 // 5 seconds

for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// Get the attribution data from Apple for the token
const response = await fetch('https://api-adservices.apple.com/api/v1/', {
method: 'POST',
headers: {
'Content-Type': 'text/plain'
},
body: attributionToken
})

// If we get a 404, wait and retry as per Apple's recommendation
if (response.status === 404 && attempt < maxRetries) {
console.log(`Apple Ads attribution API returned 404, retrying in ${retryDelay}ms (attempt ${attempt}/${maxRetries})`)
await snooze(retryDelay)
continue
}

if (!response.ok) throw new Error(`API call failed with status: ${response.status}`)

const data = await response.json()
return asAppleAdsAttribution(data)
} catch (apiError) {
console.warn('Error fetching Apple Ads attribution data:', apiError)
break
}
}
}

return { campaignId: undefined, keywordId: undefined }
}
7 changes: 7 additions & 0 deletions src/types/brigad-react-native-adservices.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
declare module '@brigad/react-native-adservices' {
/**
* Get the attribution token from Apple's AdServices framework
* @returns Promise that resolves to the attribution token or null if not available
*/
export function getAttributionToken(): Promise<string | null>
}
14 changes: 14 additions & 0 deletions src/types/react-native-apple-ads-attribution.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
declare module 'react-native-apple-ads-attribution' {
export interface AttributionData {
keywordId?: string
campaignId?: string
adGroupId?: string
[key: string]: any
}

const AppleAdsAttributionInstance: {
getAttributionData: () => Promise<AttributionData>
}

export default AppleAdsAttributionInstance
}
4 changes: 3 additions & 1 deletion src/util/tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export interface TrackingValues extends LoginTrackingValues {
numAccounts?: number // Number of full accounts saved on the device
surveyCategory2?: string // User's answer to a survey (first tier response)
surveyResponse2?: string // User's answer to a survey
appleAdsKeywordId?: string // Apple Search Ads attribution keyword ID

// Conversion values
conversionValues?: DollarConversionValues | CryptoConversionValues | SellConversionValues | BuyConversionValues | SwapConversionValues
Expand Down Expand Up @@ -206,7 +207,7 @@ export function logEvent(event: TrackingEventName, values: TrackingValues = {}):
getExperimentConfig()
.then(async (experimentConfig: ExperimentConfig) => {
// Persistent & Unchanged params:
const { isFirstOpen, deviceId, firstOpenEpoch } = await getFirstOpenInfo()
const { isFirstOpen, deviceId, firstOpenEpoch, appleAdsAttribution } = await getFirstOpenInfo()

const { error, createdWalletCurrencyCode, conversionValues, ...restValue } = values
const params: any = {
Expand All @@ -215,6 +216,7 @@ export function logEvent(event: TrackingEventName, values: TrackingValues = {}):
isFirstOpen,
deviceId,
firstOpenEpoch,
appleAdsAttribution,
...restValue
}

Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1195,6 +1195,11 @@
dependencies:
"@noble/curves" "^1.7.0"

"@brigad/react-native-adservices@^0.1.3":
version "0.1.3"
resolved "https://registry.yarnpkg.com/@brigad/react-native-adservices/-/react-native-adservices-0.1.3.tgz#7d59ba083c2cde7cefdefbe8b920cf0953ccb86e"
integrity sha512-Ri9TmT2V+kpr3XaOKFvhB6ZM4irrV/2les5ozuGThrsZUhHNJMD3EjgwPOZGEQZ1JrhUCS776RUYf1pgeOttJQ==

"@chain-registry/client@^1.53.59":
version "1.53.59"
resolved "https://registry.yarnpkg.com/@chain-registry/client/-/client-1.53.59.tgz#3d2898408be0ce5a810a4da62a0698ad52bb9297"
Expand Down
Loading