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..9c7b208 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,72 @@ 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..9ae2f09 100644 --- a/ios/RNAPS/RNAPSAdsModule.swift +++ b/ios/RNAPS/RNAPSAdsModule.swift @@ -98,6 +98,18 @@ class RNAPSAdsModule: NSObject { DTBAds.sharedInstance().testMode = enabled } + // The setters below still go through the deprecated DTBAds singleton on purpose. + // + // 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 @@ -105,7 +117,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 +125,26 @@ 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 + } + // 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 } + _ = 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';