Skip to content

Commit 1f53ea8

Browse files
committed
feat: Add iOS Apple Ads attribution API and refine consent data handling
- Introduces `setDisableAppleAdsAttribution` to allow disabling Apple Ads attribution on iOS. - Addresses a potential crash on iOS by ensuring `isUserSubjectToGDPR` defaults to `false` when omitted in `setConsentData` calls, aligning behavior with Android's native implementation. - Adds `verify-plugin-schema.js`, a new development script to validate `index.ts` RPC calls against the canonical AppsFlyer plugin schema, improving consistency checks.
1 parent 852a0de commit 1f53ea8

6 files changed

Lines changed: 291 additions & 2 deletions

File tree

‎Docs/RN_API.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ The list of available methods for this plugin is described below.
8484
- [logSession](#logsession)
8585
- [iOS Only APIs](#ios-only-apis)
8686
- [setDisableCollectASA](#setdisablecollectasa)
87+
- [setDisableAppleAdsAttribution](#setdisableappleadsattribution)
8788
- [setDisableIDFVCollection](#setdisableidfvcollection)
8889
- [setUseReceiptValidationSandbox](#setusereceiptvalidationsandbox)
8990
- [setUseUninstallSandbox](#setuseuninstallsandbox)
@@ -1009,6 +1010,8 @@ appsFlyer.setConsentData(consent1);
10091010
| hasConsentForAdsPersonalization | boolean | Consent for ads personalization (optional) |
10101011
| hasConsentForAdStorage | boolean | Consent for ad storage (optional) |
10111012

1013+
If `isUserSubjectToGDPR` is omitted, it defaults to `false`.
1014+
10121015
### logAdRevenue
10131016
`logAdRevenue(data): void`
10141017

@@ -1430,6 +1433,25 @@ appsFlyer.setDisableCollectASA(true);
14301433

14311434
---
14321435

1436+
### setDisableAppleAdsAttribution
1437+
`setDisableAppleAdsAttribution(disable)`
1438+
1439+
Disables Apple Ads attribution
1440+
1441+
| parameter | type | description |
1442+
| ------------ |----------|------------------ |
1443+
| disable | boolean | Flag to disable/enable Apple Ads attribution |
1444+
1445+
*Example:*
1446+
1447+
```javascript
1448+
if (Platform.OS == 'ios') {
1449+
appsFlyer.setDisableAppleAdsAttribution(true);
1450+
}
1451+
```
1452+
1453+
---
1454+
14331455
### setDisableIDFVCollection
14341456
`setDisableIDFVCollection(disable)`
14351457

‎Docs/RN_CMP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ appsFlyer.start();
105105
//AppsFlyerConsent Constructor:
106106

107107
new AppsFlyerConsent(
108-
isUserSubjectToGDPR, // Boolean (optional) - Whether GDPR applies to the user
108+
isUserSubjectToGDPR, // Boolean (optional, defaults to false) - Whether GDPR applies to the user
109109
hasConsentForDataUsage, // Boolean (optional) - Consent for data usage
110110
hasConsentForAdsPersonalization, // Boolean (optional) - Consent for ads personalization
111111
hasConsentForAdStorage // Boolean (optional) - Consent for ad storage

‎__tests__/index.test.js‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,19 @@ describe("Test appsFlyer API's", () => {
731731
);
732732
});
733733

734+
// Regression: iOS's native RPC parser requires isUserSubjectToGDPR (requireBool, no default)
735+
// and throws if it's missing. AppsFlyerConsent's constructor takes it as optional, so
736+
// omitting it used to reach native as `undefined` (dropped entirely by JSON.stringify).
737+
test('setConsentData defaults isUserSubjectToGDPR to false when omitted', () => {
738+
appsFlyer.setConsentData({ hasConsentForDataUsage: true });
739+
expect(NativeAppsFlyer.executeRpc).toHaveBeenCalledWith(
740+
JSON.stringify({
741+
method: 'setConsentData',
742+
params: { hasConsentForDataUsage: true, isUserSubjectToGDPR: false },
743+
})
744+
);
745+
});
746+
734747
test('AppsFlyerConsent constructor with all parameters', () => {
735748
const consent = new AppsFlyerConsent(true, true, false, true);
736749
expect(consent.isUserSubjectToGDPR).toBe(true);

‎__tests__/rpc-wire-contract.test.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,11 @@ const CALL_SITES = [
210210
// iOS-only surface
211211
{ api: 'disableIDFVCollection', platforms: [IOS], invoke: () => appsFlyer.setDisableIDFVCollection(true) },
212212
{ api: 'disableCollectASA', platforms: [IOS], invoke: () => appsFlyer.setDisableCollectASA(true) },
213+
{
214+
api: 'setDisableAppleAdsAttribution',
215+
platforms: [IOS],
216+
invoke: () => appsFlyer.setDisableAppleAdsAttribution(true),
217+
},
213218
{
214219
api: 'setUseReceiptValidationSandbox',
215220
platforms: [IOS],

‎index.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,15 @@ appsFlyer.setDisableCollectASA = (disable: boolean) => {
981981
callRpcVoid("setDisableCollectASA", { disable });
982982
};
983983

984+
/**
985+
* Disables Apple Ads attribution
986+
* @param disable Flag to disable/enable Apple Ads attribution
987+
* @platform ios
988+
*/
989+
appsFlyer.setDisableAppleAdsAttribution = (disable: boolean) => {
990+
callRpcVoid("setDisableAppleAdsAttribution", { disable });
991+
};
992+
984993
// Export AFPurchaseType enum for the new validateAndLogInAppPurchase API
985994
export const AFPurchaseType = {
986995
SUBSCRIPTION: "subscription",
@@ -1196,7 +1205,13 @@ appsFlyer.enableTCFDataCollection = (enabled: boolean) => {
11961205
* @param consentData AppsFlyerConsent object.
11971206
*/
11981207
appsFlyer.setConsentData = (consentData: AppsFlyerConsent) => {
1199-
callRpcVoid("setConsentData", consentData);
1208+
// iOS's native RPC parser requires isUserSubjectToGDPR (requireBool, no default) and throws
1209+
// if it's missing; Android already defaults it to false. AppsFlyerConsent's constructor takes
1210+
// it as optional, so mirror Android's default here rather than let iOS crash on omission.
1211+
callRpcVoid("setConsentData", {
1212+
...consentData,
1213+
isUserSubjectToGDPR: consentData?.isUserSubjectToGDPR ?? false,
1214+
});
12001215
};
12011216

12021217
class AFParseJSONException extends Error {
@@ -1530,6 +1545,10 @@ export interface AppsFlyerApi {
15301545
* For iOS Only
15311546
* */
15321547
setDisableCollectASA(disable: boolean): void;
1548+
/**
1549+
* For iOS Only
1550+
* */
1551+
setDisableAppleAdsAttribution(disable: boolean): void;
15331552
setUseReceiptValidationSandbox(sandbox: boolean): void;
15341553
setUseUninstallSandbox(sandbox: boolean): void;
15351554
setDisableSKAdNetwork(disable: boolean): void;

‎scripts/verify-plugin-schema.js‎

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Deterministic diff between index.ts and the canonical cross-plugin schema
4+
* (schemas/plugins-rpc-schema/appsflyer-plugins-rpc-schema.json, mirrored from
5+
* gitlab.appsflyer.com/mobile/appsflyer-plugins-rpc-schema).
6+
*
7+
* Parses index.ts with the TypeScript compiler API (already a devDependency) instead of
8+
* regex, so it finds every callRpc/callRpcVoid/callRpcWithCallback/dispatchRpc/onceRegistrar
9+
* call site and the literal param keys passed. Matches index.ts's dispatched (canonical) method
10+
* name against the schema's per-platform *wire* method name (schema.methods[].rpc.<platform>.method),
11+
* through the same native aliasing tables __tests__/rpc-wire-contract.test.js uses - matching
12+
* against schema.methods[].name directly is wrong, since that's the schema's public-API label,
13+
* not necessarily what's on the wire (e.g. index.ts dispatches "isDebug"; the schema's public
14+
* name for that same wire call is "enableDebug").
15+
*
16+
* Reports:
17+
* 1. schema methods index.ts never dispatches (on either platform)
18+
* 2. index.ts dispatches with no matching schema entry on either platform
19+
* 3. for methods present in both: params index.ts sends that no matched schema entry
20+
* declares, and platform-required params no call site ever sends
21+
*
22+
* This is a report, not a hard gate — some mismatches are expected (native lifecycle hooks
23+
* like continueUserActivity/handleLaunchOptions aren't invoked from JS). Read the output and
24+
* judge each line; promote confirmed-real ones into __tests__/rpc-wire-contract.test.js if you
25+
* want them CI-enforced.
26+
*
27+
* Usage: node scripts/verify-plugin-schema.js
28+
*/
29+
const fs = require("fs");
30+
const path = require("path");
31+
const ts = require("typescript");
32+
33+
const repoRoot = path.resolve(__dirname, "..");
34+
const schemaPath = path.join(
35+
repoRoot,
36+
"schemas",
37+
"plugins-rpc-schema",
38+
"appsflyer-plugins-rpc-schema.json"
39+
);
40+
const indexPath = path.join(repoRoot, "index.ts");
41+
42+
const DISPATCH_CALLEES = new Set([
43+
"callRpc",
44+
"callRpcVoid",
45+
"callRpcWithCallback",
46+
"dispatchRpc",
47+
]);
48+
49+
function callSitesFromIndexTs() {
50+
const source = fs.readFileSync(indexPath, "utf8");
51+
const sf = ts.createSourceFile(
52+
indexPath,
53+
source,
54+
ts.ScriptTarget.Latest,
55+
true,
56+
ts.ScriptKind.TS
57+
);
58+
59+
const sites = []; // { method, keys: string[] | null } - null means "can't statically verify"
60+
61+
// null = params arg isn't a plain object literal we can read keys from (an Identifier like
62+
// `callRpcVoid("setConsentData", consentData)`, or a literal containing `...spread`).
63+
function objectLiteralKeys(node) {
64+
if (!node) return [];
65+
if (!ts.isObjectLiteralExpression(node)) return null;
66+
if (node.properties.some((p) => ts.isSpreadAssignment(p))) return null;
67+
return node.properties
68+
.map((p) => p.name && ts.isIdentifier(p.name) ? p.name.text : null)
69+
.filter(Boolean);
70+
}
71+
72+
function visit(node) {
73+
if (ts.isCallExpression(node)) {
74+
const callee = node.expression;
75+
const name = ts.isIdentifier(callee) ? callee.text : null;
76+
77+
if (name && DISPATCH_CALLEES.has(name)) {
78+
const [methodArg, paramsArg] = node.arguments;
79+
if (methodArg && ts.isStringLiteral(methodArg)) {
80+
sites.push({ method: methodArg.text, keys: objectLiteralKeys(paramsArg) });
81+
}
82+
}
83+
84+
// onceRegistrar("registerDeeplinkListener") etc. - listener registration RPCs, no params.
85+
if (name === "onceRegistrar") {
86+
const [methodArg] = node.arguments;
87+
if (methodArg && ts.isStringLiteral(methodArg)) {
88+
sites.push({ method: methodArg.text, keys: [] });
89+
}
90+
}
91+
}
92+
93+
ts.forEachChild(node, visit);
94+
}
95+
visit(sf);
96+
97+
// Catch inline `"method": "..."` object literals (setUserFbLoginId-style) that skip callRpc
98+
// entirely to dodge Number()'s precision loss - params are template-literal text, not readable keys.
99+
for (const match of source.matchAll(/"method"\s*:\s*"([^"]+)"/g)) {
100+
if (!sites.some((s) => s.method === match[1])) {
101+
sites.push({ method: match[1], keys: null });
102+
}
103+
}
104+
105+
return sites;
106+
}
107+
108+
function schemaParamProps(entry, platform) {
109+
const params = entry.rpc?.[platform]?.parameters;
110+
return {
111+
properties: new Set(Object.keys(params?.properties || {})),
112+
required: new Set(params?.required || []),
113+
};
114+
}
115+
116+
// Native rewrites the dispatched method string only (never params) before reading it - mirrors
117+
// RNAppsFlyerImpl.swift `canonicalToIOSMethod` / RNAppsFlyerModule.kt `CANONICAL_TO_ANDROID_METHOD`,
118+
// copied from __tests__/rpc-wire-contract.test.js. The schema's rpc.<platform>.method is the
119+
// post-alias wire name, so matching against schema.name directly (as v1 of this script did)
120+
// produces false positives for every aliased method - go through these tables first.
121+
const IOS_METHOD_ALIASES = {
122+
init: "initialize",
123+
sendPushNotificationData: "handlePushNotification",
124+
updateServerUninstallToken: "registerUninstall",
125+
};
126+
const ANDROID_METHOD_ALIASES = {
127+
registerDeeplinkListener: "subscribeForDeepLink",
128+
};
129+
130+
function main() {
131+
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
132+
133+
// wire method name -> schema entry, per platform. A dispatched name can match a different
134+
// schema entry per platform (e.g. sendPushNotificationData's ios wire name "handlePushNotification"
135+
// is its own separate schema entry, with different params than the android-only "sendPushNotificationData" entry).
136+
const wireIndex = { ios: new Map(), android: new Map() };
137+
for (const entry of schema.methods) {
138+
for (const platform of ["ios", "android"]) {
139+
const wireMethod = entry.rpc?.[platform]?.method;
140+
if (wireMethod) wireIndex[platform].set(wireMethod, entry);
141+
}
142+
}
143+
144+
const sites = callSitesFromIndexTs();
145+
const dispatchedNames = new Set(sites.map((s) => s.method));
146+
const keysByMethod = new Map(); // dispatched name -> Set<string> | "dynamic"
147+
for (const { method, keys } of sites) {
148+
if (keysByMethod.get(method) === "dynamic") continue;
149+
if (keys === null) {
150+
keysByMethod.set(method, "dynamic");
151+
continue;
152+
}
153+
const existing = keysByMethod.get(method);
154+
const set = existing instanceof Set ? existing : new Set();
155+
keys.forEach((k) => set.add(k));
156+
keysByMethod.set(method, set);
157+
}
158+
159+
const matchedSchemaNames = new Set();
160+
const missingFromSchema = [];
161+
const paramProblems = [];
162+
const unverifiable = [];
163+
164+
for (const dispatched of dispatchedNames) {
165+
const matchByPlatform = {
166+
ios: wireIndex.ios.get(IOS_METHOD_ALIASES[dispatched] || dispatched),
167+
android: wireIndex.android.get(ANDROID_METHOD_ALIASES[dispatched] || dispatched),
168+
};
169+
const matchedEntries = [...new Set([matchByPlatform.ios, matchByPlatform.android].filter(Boolean))];
170+
171+
if (matchedEntries.length === 0) {
172+
missingFromSchema.push(dispatched);
173+
continue;
174+
}
175+
matchedEntries.forEach((e) => matchedSchemaNames.add(e.name));
176+
177+
const sentKeys = keysByMethod.get(dispatched);
178+
if (sentKeys === "dynamic") {
179+
unverifiable.push(dispatched);
180+
continue; // params built from a spread/variable - can't check keys statically
181+
}
182+
183+
const knownAnywhere = new Set();
184+
for (const platform of ["ios", "android"]) {
185+
const entry = matchByPlatform[platform];
186+
if (!entry) continue;
187+
const { properties, required } = schemaParamProps(entry, platform);
188+
properties.forEach((p) => knownAnywhere.add(p));
189+
for (const req of required) {
190+
if (!sentKeys.has(req)) {
191+
paramProblems.push(
192+
`"${dispatched}" (schema "${entry.name}"): ${platform} requires "${req}", but no call site sends it`
193+
);
194+
}
195+
}
196+
}
197+
for (const key of sentKeys) {
198+
if (!knownAnywhere.has(key)) {
199+
paramProblems.push(`"${dispatched}": sends param "${key}" that no matched schema entry declares`);
200+
}
201+
}
202+
}
203+
204+
const missingFromIndex = schema.methods
205+
.map((m) => m.name)
206+
.filter((n) => !matchedSchemaNames.has(n))
207+
.sort();
208+
missingFromSchema.sort();
209+
210+
console.log(`schema methods: ${schema.methods.length}`);
211+
console.log(`index.ts dispatch sites: ${dispatchedNames.size}\n`);
212+
213+
console.log(`=== in schema, never dispatched by index.ts (${missingFromIndex.length}) ===`);
214+
missingFromIndex.forEach((n) => console.log(` ${n}`));
215+
216+
console.log(`\n=== dispatched by index.ts, not in schema (${missingFromSchema.length}) ===`);
217+
missingFromSchema.forEach((n) => console.log(` ${n}`));
218+
219+
console.log(`\n=== param mismatches on methods present in both (${paramProblems.length}) ===`);
220+
paramProblems.forEach((p) => console.log(` ${p}`));
221+
222+
console.log(`\n=== params built dynamically (spread/variable) - not checked (${unverifiable.length}) ===`);
223+
unverifiable.sort().forEach((m) => console.log(` ${m}`));
224+
225+
const total = missingFromIndex.length + missingFromSchema.length + paramProblems.length;
226+
console.log(`\n${total} total finding(s).`);
227+
process.exit(total ? 1 : 0);
228+
}
229+
230+
main();

0 commit comments

Comments
 (0)