1
+ import { getAttributionToken } from '@brigad/react-native-adservices'
1
2
import { asNumber , asObject , asOptional , asString , asValue } from 'cleaners'
2
3
import { makeReactNativeDisklet } from 'disklet'
4
+ import { Platform } from 'react-native'
3
5
4
6
import { FIRST_OPEN } from '../constants/constantSettings'
5
7
import { makeUuid } from '../util/rnUtils'
8
+ import { snooze } from '../util/utils'
6
9
import { getCountryCodeByIp } from './AccountReferralActions'
7
10
8
11
export const firstOpenDisklet = makeReactNativeDisklet ( )
9
12
10
- const asFirstOpenInfo = asObject ( {
13
+ const asAppleAdsAttribution = asObject ( {
14
+ campaignId : asOptional ( asString ) ,
15
+ keywordId : asOptional ( asString )
16
+ } )
17
+ type AppleAdsAttribution = ReturnType < typeof asAppleAdsAttribution >
18
+
19
+ interface FirstOpenInfo {
20
+ isFirstOpen : 'true' | 'false'
21
+ deviceId : string
22
+ firstOpenEpoch : number
23
+ countryCode ?: string
24
+ appleAdsAttribution ?: AppleAdsAttribution
25
+ }
26
+ type FirstOpenInfoFile = Omit < FirstOpenInfo , 'appleAdsAttribution' >
27
+
28
+ const asFirstOpenInfoFile = asObject < FirstOpenInfoFile > ( {
11
29
isFirstOpen : asValue ( 'true' , 'false' ) ,
12
30
deviceId : asString ,
13
31
firstOpenEpoch : asNumber ,
14
32
countryCode : asOptional ( asString )
15
33
} )
16
- type FirstOpenInfo = ReturnType < typeof asFirstOpenInfo >
17
34
18
35
let firstOpenInfo : FirstOpenInfo
19
36
let firstLoadPromise : Promise < FirstOpenInfo > | undefined
@@ -40,11 +57,19 @@ const readFirstOpenInfoFromDisk = async (): Promise<FirstOpenInfo> => {
40
57
let firstOpenText
41
58
try {
42
59
firstOpenText = await firstOpenDisklet . getText ( FIRST_OPEN )
43
- firstOpenInfo = asFirstOpenInfo ( JSON . parse ( firstOpenText ) )
44
- firstOpenInfo . isFirstOpen = 'false'
60
+ // Parse the file data using the file-specific cleaner
61
+ const fileData = asFirstOpenInfoFile ( JSON . parse ( firstOpenText ) )
62
+ // Create the full in-memory object with attribution data
63
+ firstOpenInfo = {
64
+ ...fileData ,
65
+ isFirstOpen : 'false' ,
66
+ appleAdsAttribution : await getAppleAdsAttribution ( )
67
+ }
45
68
} catch ( error : unknown ) {
46
69
// Generate new values.
47
- firstOpenInfo = {
70
+
71
+ // Create file data object (without attribution)
72
+ const fileData : FirstOpenInfoFile = {
48
73
deviceId : await makeUuid ( ) ,
49
74
firstOpenEpoch : Date . now ( ) ,
50
75
countryCode : await getCountryCodeByIp ( ) ,
@@ -53,11 +78,78 @@ const readFirstOpenInfoFromDisk = async (): Promise<FirstOpenInfo> => {
53
78
// date, just created an empty file.
54
79
// Note that 'firstOpenEpoch' won't be accurate in this case, but at
55
80
// least make a starting point.
81
+
56
82
isFirstOpen : firstOpenText != null ? 'false' : 'true'
57
83
}
58
- await firstOpenDisklet . setText ( FIRST_OPEN , JSON . stringify ( firstOpenInfo ) )
84
+
85
+ // Create the full in-memory object
86
+ firstOpenInfo = {
87
+ ...fileData ,
88
+ appleAdsAttribution : await getAppleAdsAttribution ( )
89
+ }
90
+
91
+ // Only save the file-specific data to disk
92
+ await firstOpenDisklet . setText ( FIRST_OPEN , JSON . stringify ( fileData ) )
59
93
}
60
94
}
61
95
62
96
return firstOpenInfo
63
97
}
98
+
99
+ /**
100
+ * Get Apple Search Ads attribution data using the AdServices framework
101
+ * and make an API call to get the actual keywordId.
102
+ */
103
+ export async function getAppleAdsAttribution ( ) : Promise < AppleAdsAttribution > {
104
+ if ( Platform . OS !== 'ios' ) {
105
+ return { campaignId : undefined , keywordId : undefined }
106
+ }
107
+
108
+ // Get the attribution token from the device. This package also handles
109
+ // checking for the required iOS version.
110
+ const attributionToken = await getAttributionToken ( ) . catch ( error => {
111
+ console . log ( 'Apple Ads attribution token unavailable:' , error )
112
+ return undefined
113
+ } )
114
+
115
+ // Send the token to Apple's API to retrieve the campaign and keyword IDs.
116
+ if ( attributionToken != null ) {
117
+ // Retry logic as recommended by Apple:
118
+ // "A 404 response can occur if you make an API call too quickly after
119
+ // receiving a valid token. A best practice is to initiate retries at
120
+ // intervals of 5 seconds, with a maximum of three attempts."
121
+ // https://developer.apple.com/documentation/adservices/aaattribution/attributiontoken()#Attribution-payload
122
+ const maxRetries = 3
123
+ const retryDelay = 5000 // 5 seconds
124
+
125
+ for ( let attempt = 1 ; attempt <= maxRetries ; attempt ++ ) {
126
+ try {
127
+ // Get the attribution data from Apple for the token
128
+ const response = await fetch ( 'https://api-adservices.apple.com/api/v1/' , {
129
+ method : 'POST' ,
130
+ headers : {
131
+ 'Content-Type' : 'text/plain'
132
+ } ,
133
+ body : attributionToken
134
+ } )
135
+
136
+ // If we get a 404, wait and retry as per Apple's recommendation
137
+ if ( response . status === 404 && attempt < maxRetries ) {
138
+ console . log ( `Apple Ads attribution API returned 404, retrying in ${ retryDelay } ms (attempt ${ attempt } /${ maxRetries } )` )
139
+ await snooze ( retryDelay )
140
+ continue
141
+ }
142
+
143
+ if ( ! response . ok ) throw new Error ( `API call failed with status: ${ response . status } ` )
144
+
145
+ const data = await response . json ( )
146
+ return asAppleAdsAttribution ( data )
147
+ } catch ( apiError ) {
148
+ console . warn ( 'Error fetching Apple Ads attribution data:' , apiError )
149
+ break
150
+ }
151
+ }
152
+ }
153
+
154
+ return { campaignId : undefined , keywordId : undefined }
155
+ }
0 commit comments