From 03d01309aa47a732cc9863e0d700cb78b69507bb Mon Sep 17 00:00:00 2001 From: Jerome Bonfort Date: Wed, 9 Sep 2026 17:42:20 +0200 Subject: [PATCH 1/4] feat: expose setExternalUserIds and upgrade the APS SDKs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amazon forwards third-party identifiers (ID5, LiveRamp...) to the TAM/UAM bidders a publisher has enabled, but only if the app pushes them. There is no portal switch for this — it is an SDK API — so the app repo had no way to reach it. APSAds.setExternalUserIds(eids) takes the eid in its OpenRTB user.eids shape and passes it through untouched: atype and ext are opaque to us and belong to whoever issued the id. An empty array clears them, which is what a consent withdrawal needs. SDK upgrades, both required because the API simply does not exist in what we pinned: - Android aps-sdk 11.1.1 -> 12.0.1. AdRegistration.setExternalUserIds lands in 11.3.0 (bisected across the published AARs), so 12.0.1 is comfortably past it. - iOS AmazonPublisherServicesSDK 5.3.1 -> 5.6.4. APSExternalUserId appears between 5.6.2 and 5.6.4; 5.6.1 does not have it. Checked while bumping, so nobody has to re-check: aps-sdk ships no .so, so the 16 KB page-size requirement of Android API 36 does not apply; it pulls iabtcf-decoder 2.0.10, the version this package already pins, so no conflict; minSdk stays at 23. And Android 12.0.1 still has no equivalent of +[APS setContentUrl:], so the note in the loader stands — its version reference is updated. On iOS the new API is only non-deprecated on the APS class, so addCustomAttribute moves there too. The other five DTBAds calls stay where they are, deliberately: Amazon deprecates them in favour of +[APS setTestMode:], +[APS setUseGeoLocation:], +[APS setMraidPolicy:] and +[APS setMraidSupportedVersions:], but none of those are declared in APS.h. The selectors do exist in the binary — Amazon shipped the code and never exposed it — so Swift cannot see them and declaring them ourselves would bind us to an undeclared API. removeCustomAttribute has no APS counterpart at all. Adds 6 tests. 42 green. Co-Authored-By: Claude Opus 5 --- RNAmazonPublisherServices.podspec | 2 +- android/build.gradle | 2 +- .../adversport/rnaps/RNAPSAdLoaderModule.java | 2 +- .../com/adversport/rnaps/RNAPSAdsModule.java | 70 +++++++++++++++++++ ios/RNAPS/RNAPSAdsModule.m | 2 + ios/RNAPS/RNAPSAdsModule.swift | 29 +++++++- package.json | 2 +- src/APSAds.ts | 22 ++++++ src/__tests__/APSAds.test.ts | 48 +++++++++++++ src/internal/__mocks__/AdsModule.ts | 1 + src/turbomodules/NativeRNAPSAdsModule.ts | 2 + src/types/ExternalUserId.ts | 44 ++++++++++++ src/types/index.ts | 1 + 13 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 src/types/ExternalUserId.ts diff --git a/RNAmazonPublisherServices.podspec b/RNAmazonPublisherServices.podspec index ec2c46f..4ea3314 100644 --- a/RNAmazonPublisherServices.podspec +++ b/RNAmazonPublisherServices.podspec @@ -17,5 +17,5 @@ Pod::Spec.new do |s| install_modules_dependencies(s) - s.dependency "AmazonPublisherServicesSDK", "5.3.1" + s.dependency "AmazonPublisherServicesSDK", "5.6.4" end diff --git a/android/build.gradle b/android/build.gradle index 97d45d4..d345b35 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -45,7 +45,7 @@ dependencies { } else { implementation 'com.facebook.react:react-native:+' } - implementation("com.amazon.android:aps-sdk:11.1.1") + implementation("com.amazon.android:aps-sdk:12.0.1") implementation("com.iabtcf:iabtcf-decoder:2.0.10") } diff --git a/android/src/main/java/com/adversport/rnaps/RNAPSAdLoaderModule.java b/android/src/main/java/com/adversport/rnaps/RNAPSAdLoaderModule.java index 65e1ffb..af0df76 100644 --- a/android/src/main/java/com/adversport/rnaps/RNAPSAdLoaderModule.java +++ b/android/src/main/java/com/adversport/rnaps/RNAPSAdLoaderModule.java @@ -257,7 +257,7 @@ public void loadAd(int loaderId, String adType, ReadableMap options, Promise pro // NOTE: options.contentUrl is intentionally ignored here. // // Amazon DSP asks for the public web URL of the content being viewed, but - // the Android APS SDK (com.amazon.android:aps-sdk 11.1.1) exposes no + // the Android APS SDK (com.amazon.android:aps-sdk 12.0.1) exposes no // equivalent of the iOS +[APS setContentUrl:] — verified by inspecting every // public member of AdRegistration and DTBAdRequest. There is no supported // way to attach it to an Android bid request today. diff --git a/android/src/main/java/com/adversport/rnaps/RNAPSAdsModule.java b/android/src/main/java/com/adversport/rnaps/RNAPSAdsModule.java index e14ee68..76e0802 100644 --- a/android/src/main/java/com/adversport/rnaps/RNAPSAdsModule.java +++ b/android/src/main/java/com/adversport/rnaps/RNAPSAdsModule.java @@ -18,6 +18,7 @@ * along with Foobar. If not, see . */ +import com.amazon.aps.ads.common.ApsExternalUserId; import com.amazon.device.ads.AdRegistration; import com.amazon.device.ads.DTBAdNetwork; import com.amazon.device.ads.DTBAdNetworkInfo; @@ -25,6 +26,9 @@ import com.facebook.react.bridge.*; import com.facebook.react.module.annotations.ReactModule; import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; @ReactModule(name = RNAPSAdsModule.MODULE_NAME) public class RNAPSAdsModule extends ReactContextBaseJavaModule { @@ -153,4 +157,70 @@ public void addCustomAttribute(String key, String value) { public void removeCustomAttribute(String key) { AdRegistration.removeCustomAttribute(key); } + + /** + * Third-party identifiers (ID5, LiveRamp...), forwarded by Amazon to the TAM/UAM bidders the + * publisher has enabled. Set once per user session; call again when an id changes. Passing an + * empty array clears them, which is what a consent withdrawal must do. + */ + @ReactMethod + public void setExternalUserIds(ReadableArray externalUserIds) { + List ids = new ArrayList<>(); + + for (int i = 0; i < externalUserIds.size(); i++) { + ReadableMap entry = externalUserIds.getMap(i); + if (entry == null || !entry.hasKey("source")) { + continue; + } + String source = entry.getString("source"); + ReadableArray uids = entry.hasKey("uids") ? entry.getArray("uids") : null; + if (source == null || uids == null || uids.size() == 0) { + continue; + } + + ApsExternalUserId.Builder builder = ApsExternalUserId.Companion.builder().addSource(source); + boolean hasUid = false; + + for (int j = 0; j < uids.size(); j++) { + ReadableMap uid = uids.getMap(j); + if (uid == null || !uid.hasKey("id")) { + continue; + } + String id = uid.getString("id"); + if (id == null) { + continue; + } + Integer atype = uid.hasKey("atype") ? uid.getInt("atype") : null; + builder.addUniqueId(id, atype, readStringMap(uid, "ext")); + hasUid = true; + } + + if (hasUid) { + ids.add(builder.build()); + } + } + + AdRegistration.setExternalUserIds(ids); + } + + /** Reads a nested string map, skipping any non-string value rather than failing the whole call. */ + private static Map readStringMap(ReadableMap parent, String key) { + if (!parent.hasKey(key)) { + return null; + } + ReadableMap map = parent.getMap(key); + if (map == null) { + return null; + } + Map out = new HashMap<>(); + ReadableMapKeySetIterator iterator = map.keySetIterator(); + while (iterator.hasNextKey()) { + String k = iterator.nextKey(); + String v = map.getString(k); + if (v != null) { + out.put(k, v); + } + } + return out.isEmpty() ? null : out; + } } diff --git a/ios/RNAPS/RNAPSAdsModule.m b/ios/RNAPS/RNAPSAdsModule.m index 3b37408..b3f37af 100644 --- a/ios/RNAPS/RNAPSAdsModule.m +++ b/ios/RNAPS/RNAPSAdsModule.m @@ -39,4 +39,6 @@ @interface RCT_EXTERN_MODULE (RNAPSAdsModule, NSObject) RCT_EXTERN_METHOD(removeCustomAttribute : (nonnull NSString *)key) +RCT_EXTERN_METHOD(setExternalUserIds : (nonnull NSArray *)externalUserIds) + @end diff --git a/ios/RNAPS/RNAPSAdsModule.swift b/ios/RNAPS/RNAPSAdsModule.swift index c195224..c79f13f 100644 --- a/ios/RNAPS/RNAPSAdsModule.swift +++ b/ios/RNAPS/RNAPSAdsModule.swift @@ -98,6 +98,12 @@ class RNAPSAdsModule: NSObject { DTBAds.sharedInstance().testMode = enabled } + // The setters below still go through the deprecated DTBAds singleton on purpose. + // APS 5.6.4 deprecates them in favour of +[APS setTestMode:], +[APS setUseGeoLocation:], + // +[APS setMraidPolicy:] and +[APS setMraidSupportedVersions:], but none of those are + // declared in APS.h — the selectors exist in the binary yet were never exposed, so Swift + // cannot see them. Declaring them ourselves would bind us to an undeclared API. Revisit + // when Amazon publishes the headers; removeCustomAttribute has no APS equivalent at all. @objc(setUseGeoLocation:) func setUseGeoLocation(enabled: Bool) -> Void { DTBAds.sharedInstance().useGeoLocation = enabled @@ -105,7 +111,7 @@ class RNAPSAdsModule: NSObject { @objc(addCustomAttribute:value:) func addCustomAttribute(key: String, value: String) { - DTBAds.sharedInstance().addCustomAttribute(key, value: value) + APS.setCustomAttribute(value, forKey: key) } @objc(removeCustomAttribute:) @@ -113,4 +119,25 @@ class RNAPSAdsModule: NSObject { DTBAds.sharedInstance().removeCustomAttribute(key) } + // Third-party identifiers (ID5, LiveRamp...), forwarded by Amazon to the TAM/UAM bidders + // the publisher has enabled. Set once per user session; call again when an id changes. + // Passing an empty array clears them, which is what a consent withdrawal must do. + @objc(setExternalUserIds:) + func setExternalUserIds(externalUserIds: [[String: Any]]) { + let ids: [APSExternalUserId] = externalUserIds.compactMap { entry in + guard let source = entry["source"] as? String, + let uids = entry["uids"] as? [[String: Any]], !uids.isEmpty else { + return nil + } + let builder = APSExternalUserIdBuilder.builder() + _ = builder.addSource(source) + for uid in uids { + guard let id = uid["id"] as? String else { continue } + _ = builder.addUniqueId(id, atype: uid["atype"] as? NSNumber, ext: uid["ext"] as? [String: String]) + } + return builder.build() + } + APS.setExternalUserIds(ids) + } + } diff --git a/package.json b/package.json index 50e4f14..27b77ad 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "react-native-aps", "packageManager": "yarn@4.3.1", - "version": "2.3.0", + "version": "2.4.0", "author": "AdversportTeam (https://github.com/AdversportTeam)", "contributors": [ "Jay Kim (https://github.com/wjaykim)" diff --git a/src/APSAds.ts b/src/APSAds.ts index 77f668d..a94c2a5 100644 --- a/src/APSAds.ts +++ b/src/APSAds.ts @@ -22,6 +22,10 @@ import { validateAdNetworkInfo, } from './types/AdNetworkInfo'; import { isMRAIDPolicy, MRAIDPolicy } from './types/MRAIDPolicy'; +import { + type ExternalUserId, + validateExternalUserIds, +} from './types/ExternalUserId'; export class APSAds { private static _nativeModule = AdsModule; @@ -129,4 +133,22 @@ export class APSAds { } return this._nativeModule.removeCustomAttribute(key); } + + /** + * Sets the third-party user identifiers (ID5, LiveRamp...) sent with every bid request. + * Amazon forwards them to the TAM/UAM bidders the publisher has enabled. + * + * Set once per user session, and call again whenever an id changes. Pass an empty array to + * clear them, which is what a consent withdrawal must do. + */ + static setExternalUserIds(externalUserIds: ExternalUserId[]): void { + try { + validateExternalUserIds(externalUserIds); + } catch (e) { + if (e instanceof Error) { + throw new Error(`APSAds.setExternalUserIds(*) ${e.message}`); + } + } + return this._nativeModule.setExternalUserIds(externalUserIds); + } } diff --git a/src/__tests__/APSAds.test.ts b/src/__tests__/APSAds.test.ts index 4b2995a..431456e 100644 --- a/src/__tests__/APSAds.test.ts +++ b/src/__tests__/APSAds.test.ts @@ -1,4 +1,5 @@ import { APSAds } from '../APSAds'; +import AdsModule from '../internal/AdsModule'; import { AdNetwork, MRAIDPolicy } from '../types'; jest.mock('../internal/AdsModule'); @@ -108,6 +109,53 @@ describe('APSAds', function () { expect(APSAds.addCustomAttribute('key', 'value')).toBeUndefined(); }); }); + describe('setExternalUserIds', function () { + const anId5Eid = { + source: 'id5-sync.com', + uids: [{ id: 'ID5*abc', atype: 2, ext: { linkType: '2' } }], + }; + + it('throws if externalUserIds is not an array', function () { + // @ts-ignore + expect(() => APSAds.setExternalUserIds('nope')).toThrowError( + "APSAds.setExternalUserIds(*) 'externalUserIds' expected an array value" + ); + }); + it('throws if source is missing', function () { + expect(() => + // @ts-ignore + APSAds.setExternalUserIds([{ uids: [{ id: 'a' }] }]) + ).toThrowError( + "APSAds.setExternalUserIds(*) 'externalUserIds[0].source' expected a string value" + ); + }); + it('throws if uids is empty', function () { + expect(() => + APSAds.setExternalUserIds([{ source: 'id5-sync.com', uids: [] }]) + ).toThrowError( + "APSAds.setExternalUserIds(*) 'externalUserIds[0].uids' expected a non-empty array value" + ); + }); + it('throws if a uid has no id', function () { + expect(() => + APSAds.setExternalUserIds([ + // @ts-ignore + { source: 'id5-sync.com', uids: [{ atype: 2 }] }, + ]) + ).toThrowError( + "APSAds.setExternalUserIds(*) 'externalUserIds[0].uids[0].id' expected a string value" + ); + }); + it('accepts an eid as returned by the issuer, untouched', function () { + expect(APSAds.setExternalUserIds([anId5Eid])).toBeUndefined(); + expect(AdsModule.setExternalUserIds).toHaveBeenCalledWith([anId5Eid]); + }); + it('accepts an empty array, which clears the ids', function () { + expect(APSAds.setExternalUserIds([])).toBeUndefined(); + expect(AdsModule.setExternalUserIds).toHaveBeenCalledWith([]); + }); + }); + describe('removeCustomAttribute', function () { it('throws if key is invalid', function () { // @ts-ignore diff --git a/src/internal/__mocks__/AdsModule.ts b/src/internal/__mocks__/AdsModule.ts index 577ecf0..205ed84 100644 --- a/src/internal/__mocks__/AdsModule.ts +++ b/src/internal/__mocks__/AdsModule.ts @@ -7,6 +7,7 @@ const AdsModule = { setUseGeoLocation: jest.fn(), addCustomAttribute: jest.fn(), removeCustomAttribute: jest.fn(), + setExternalUserIds: jest.fn(), }; export default AdsModule; diff --git a/src/turbomodules/NativeRNAPSAdsModule.ts b/src/turbomodules/NativeRNAPSAdsModule.ts index bbcbf61..98808a9 100644 --- a/src/turbomodules/NativeRNAPSAdsModule.ts +++ b/src/turbomodules/NativeRNAPSAdsModule.ts @@ -45,6 +45,8 @@ export interface Spec extends TurboModule { addCustomAttribute: (key: string, value: string) => void; removeCustomAttribute: (key: string) => void; + + setExternalUserIds: (externalUserIds: Object[]) => void; } export default TurboModuleRegistry.getEnforcing('RNAPSAdsModule'); diff --git a/src/types/ExternalUserId.ts b/src/types/ExternalUserId.ts new file mode 100644 index 0000000..0633126 --- /dev/null +++ b/src/types/ExternalUserId.ts @@ -0,0 +1,44 @@ +/** + * A third-party user identifier, in the OpenRTB `user.eids` shape. + * + * Amazon forwards these to the TAM/UAM bidders the publisher has enabled. The object is + * meant to be passed through untouched from whoever issued it (ID5, LiveRamp...): `atype` + * and `ext` are opaque to us and must not be rebuilt. + * + * @see https://resources.ams.amazon.com/s/article/external-user-ids + */ +export interface ExternalUserId { + /** Identifier domain, e.g. `id5-sync.com`. */ + source: string; + uids: Array<{ + id: string; + /** OpenRTB agent type. 1 = probabilistic, 2 = device advertising id. */ + atype?: number; + ext?: Record; + }>; +} + +export function validateExternalUserIds(externalUserIds: ExternalUserId[]) { + if (!Array.isArray(externalUserIds)) { + throw new Error("'externalUserIds' expected an array value"); + } + externalUserIds.forEach((externalUserId, index) => { + if (typeof externalUserId?.source !== 'string') { + throw new Error( + `'externalUserIds[${index}].source' expected a string value` + ); + } + if (!Array.isArray(externalUserId.uids) || !externalUserId.uids.length) { + throw new Error( + `'externalUserIds[${index}].uids' expected a non-empty array value` + ); + } + externalUserId.uids.forEach((uid, uidIndex) => { + if (typeof uid?.id !== 'string') { + throw new Error( + `'externalUserIds[${index}].uids[${uidIndex}].id' expected a string value` + ); + } + }); + }); +} diff --git a/src/types/index.ts b/src/types/index.ts index 6841042..b1045cd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -23,3 +23,4 @@ export { AdNetwork } from './AdNetwork'; export type { AdNetworkInfo } from './AdNetworkInfo'; export { AdType } from './AdType'; export { MRAIDPolicy } from './MRAIDPolicy'; +export * from './ExternalUserId'; From 8727f9406a1c0ae5339651bc9063baf7a26c766c Mon Sep 17 00:00:00 2001 From: Jerome Bonfort Date: Wed, 9 Sep 2026 17:59:11 +0200 Subject: [PATCH 2/4] docs: correct why the deprecated iOS setters stay on DTBAds They are not missing from the SDK, they moved: APS 5.6.4 carries testMode, useGeolocation, mraidPolicy and mraidSupportedVersions on APSInitConfig, handed once to +[APS initializeWithAppKey:config:completion:]. Adopting that reshapes this bridge's contract, so it belongs in its own change. The deprecation text is misleading and the comment now says so: it points at +[APS setTestMode:] and friends, which APS.h never declares. Co-Authored-By: Claude Opus 5 --- ios/RNAPS/RNAPSAdsModule.swift | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/ios/RNAPS/RNAPSAdsModule.swift b/ios/RNAPS/RNAPSAdsModule.swift index c79f13f..3841778 100644 --- a/ios/RNAPS/RNAPSAdsModule.swift +++ b/ios/RNAPS/RNAPSAdsModule.swift @@ -99,11 +99,17 @@ class RNAPSAdsModule: NSObject { } // The setters below still go through the deprecated DTBAds singleton on purpose. - // APS 5.6.4 deprecates them in favour of +[APS setTestMode:], +[APS setUseGeoLocation:], - // +[APS setMraidPolicy:] and +[APS setMraidSupportedVersions:], but none of those are - // declared in APS.h — the selectors exist in the binary yet were never exposed, so Swift - // cannot see them. Declaring them ourselves would bind us to an undeclared API. Revisit - // when Amazon publishes the headers; removeCustomAttribute has no APS equivalent at all. + // + // Their replacements are not setters at all: APS 5.6.4 moved testMode, useGeolocation, + // mraidPolicy and mraidSupportedVersions onto APSInitConfig, an object handed once to + // +[APS initializeWithAppKey:config:completion:]. Adopting them means reshaping this + // bridge's contract — JS calls initialize() first and these setters afterwards, which + // the config model cannot express — so it belongs in its own change, not here. + // + // Do not trust the deprecation text: it points at +[APS setTestMode:] and friends, which + // are declared nowhere in APS.h (the selectors do exist in the binary). Read APSInitConfig.h. + // + // removeCustomAttribute has no replacement at all, on APS or on APSInitConfig. @objc(setUseGeoLocation:) func setUseGeoLocation(enabled: Bool) -> Void { DTBAds.sharedInstance().useGeoLocation = enabled From 6eb0708ef4bcdc15bab8e664568f0f91082912c8 Mon Sep 17 00:00:00 2001 From: Jerome Bonfort Date: Wed, 9 Sep 2026 18:47:06 +0200 Subject: [PATCH 3/4] style: wrap a javadoc line to satisfy google-java-format The CI runs lint:code, which chains lint:js:check, lint:android:check and lint:ios:check. I had only run the JS one locally, so a single over-long javadoc line in RNAPSAdsModule slipped through and failed the Lint job. Co-Authored-By: Claude Opus 5 --- .../src/main/java/com/adversport/rnaps/RNAPSAdsModule.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/adversport/rnaps/RNAPSAdsModule.java b/android/src/main/java/com/adversport/rnaps/RNAPSAdsModule.java index 76e0802..9c7b208 100644 --- a/android/src/main/java/com/adversport/rnaps/RNAPSAdsModule.java +++ b/android/src/main/java/com/adversport/rnaps/RNAPSAdsModule.java @@ -203,7 +203,9 @@ public void setExternalUserIds(ReadableArray externalUserIds) { AdRegistration.setExternalUserIds(ids); } - /** Reads a nested string map, skipping any non-string value rather than failing the whole call. */ + /** + * Reads a nested string map, skipping any non-string value rather than failing the whole call. + */ private static Map readStringMap(ReadableMap parent, String key) { if (!parent.hasKey(key)) { return null; From 81413accbbb8d79917750ba93bc07b3a0864bcb2 Mon Sep 17 00:00:00 2001 From: Jerome Bonfort Date: Wed, 9 Sep 2026 19:00:51 +0200 Subject: [PATCH 4/4] fix(ios): build the APS external user id with the Swift initializer Swift imports the +builder class factory as an initializer, so APSExternalUserIdBuilder.builder() does not exist on the Swift side and the module failed to compile against AmazonPublisherServicesSDK 5.6.4. Caught by compiling the pod for the simulator; the CI only runs jest and the linters, neither of which touches the native sources. Co-Authored-By: Claude Opus 5 --- ios/RNAPS/RNAPSAdsModule.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ios/RNAPS/RNAPSAdsModule.swift b/ios/RNAPS/RNAPSAdsModule.swift index 3841778..9ae2f09 100644 --- a/ios/RNAPS/RNAPSAdsModule.swift +++ b/ios/RNAPS/RNAPSAdsModule.swift @@ -135,7 +135,8 @@ class RNAPSAdsModule: NSObject { let uids = entry["uids"] as? [[String: Any]], !uids.isEmpty else { return nil } - let builder = APSExternalUserIdBuilder.builder() + // Swift imports the +builder factory as an initializer, so `.builder()` does not exist here. + let builder = APSExternalUserIdBuilder() _ = builder.addSource(source) for uid in uids { guard let id = uid["id"] as? String else { continue }